mirror of
https://github.com/gesellix/Bose-SoundTouch.git
synced 2026-08-18 16:46:17 +00:00
Root cause of the failed TTS playback: the /speaker notification path makes the speaker validate the app_key via GET /v1/auth against the service, which returned 404 -> the speaker reports an invalid app key (HandleInvalidAppKeyCb) and refuses to play. Our /media/tts hosting was fine all along (confirmed by a direct GET returning the mp3). Two fixes: - TTS speak now plays the synthesized clip as a LOCAL_INTERNET_RADIO ContentItem via the /custom/v1/playback proxy (the same mechanism the "ding" health check uses), which needs no app_key. New buildCustomPlaybackURL helper + tts.Service.BaseURL(). - Add GET /v1/auth -> 200 so the /speaker notification path also works (we're the cloud replacement; a 404 there is read as "invalid app key"). Includes a TEMPORARY full-request debug dump on /v1/auth to learn how the speaker presents the app_key; to be removed later. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
146 lines
4.2 KiB
Go
146 lines
4.2 KiB
Go
package tts
|
|
|
|
import (
|
|
"context"
|
|
"crypto/sha256"
|
|
"encoding/hex"
|
|
"fmt"
|
|
"strings"
|
|
"time"
|
|
)
|
|
|
|
// Config configures a Service.
|
|
type Config struct {
|
|
// BaseURL is the service's public URL the speaker can reach (e.g.
|
|
// "https://soundtouch.local"). Used to build /media/tts/{id} URLs for
|
|
// synthesized clips. Not needed for direct-URL providers.
|
|
BaseURL string
|
|
// AppKey is the Bose /speaker app_key. The Service does not use it itself;
|
|
// it is surfaced to the handler that POSTs play_info to the speaker.
|
|
AppKey string
|
|
// DefaultLanguage / DefaultVoice / DefaultVolume fill in unset Request
|
|
// fields. Defaults must match the active provider's expectations.
|
|
DefaultLanguage string
|
|
DefaultVoice string
|
|
DefaultVolume int
|
|
// CacheTTL and CacheMaxEntries bound the synthesized-clip cache.
|
|
CacheTTL time.Duration
|
|
CacheMaxEntries int
|
|
}
|
|
|
|
// Service orchestrates a Provider plus a clip cache to turn text into a single
|
|
// speaker-playable URL. It is provider-agnostic: callers use Prepare and never
|
|
// see the direct-URL vs. byte-synthesis distinction.
|
|
type Service struct {
|
|
provider Provider
|
|
cache *clipCache
|
|
cfg Config
|
|
}
|
|
|
|
// NewService builds a Service around the given provider and config.
|
|
func NewService(provider Provider, cfg Config) *Service {
|
|
return &Service{
|
|
provider: provider,
|
|
cache: newClipCache(cfg.CacheTTL, cfg.CacheMaxEntries),
|
|
cfg: cfg,
|
|
}
|
|
}
|
|
|
|
// ProviderName returns the active provider's identifier.
|
|
func (s *Service) ProviderName() string { return s.provider.Name() }
|
|
|
|
// BaseURL returns the configured public service URL (used to build the
|
|
// /custom/v1/playback LOCAL_INTERNET_RADIO proxy URL for playback).
|
|
func (s *Service) BaseURL() string { return s.cfg.BaseURL }
|
|
|
|
// AppKey returns the configured Bose /speaker app_key.
|
|
func (s *Service) AppKey() string { return s.cfg.AppKey }
|
|
|
|
// DefaultVolume returns the configured default playback volume (0 = current).
|
|
func (s *Service) DefaultVolume() int { return s.cfg.DefaultVolume }
|
|
|
|
// DefaultLanguage returns the configured default language.
|
|
func (s *Service) DefaultLanguage() string { return s.cfg.DefaultLanguage }
|
|
|
|
// DefaultVoice returns the configured default voice.
|
|
func (s *Service) DefaultVoice() string { return s.cfg.DefaultVoice }
|
|
|
|
// Prepare produces a speaker-playable URL for req. For direct-URL providers it
|
|
// returns the provider's URL; for synthesizing providers it caches the audio
|
|
// and returns a local /media/tts/{id} URL. Repeated identical requests reuse
|
|
// the cached clip.
|
|
func (s *Service) Prepare(ctx context.Context, req Request) (string, error) {
|
|
req = s.applyDefaults(req)
|
|
|
|
if strings.TrimSpace(req.Text) == "" {
|
|
return "", fmt.Errorf("tts: text is empty")
|
|
}
|
|
|
|
id := clipID(req)
|
|
if _, _, ok := s.cache.get(id); ok {
|
|
return s.mediaURL(id), nil
|
|
}
|
|
|
|
res, err := s.provider.Synthesize(ctx, req)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
|
|
if res.DirectURL != "" {
|
|
return res.DirectURL, nil
|
|
}
|
|
|
|
if len(res.Audio) == 0 {
|
|
return "", fmt.Errorf("tts: provider returned no audio")
|
|
}
|
|
|
|
if strings.TrimSpace(s.cfg.BaseURL) == "" {
|
|
return "", fmt.Errorf("tts: provider returned audio but no base URL is configured to host it")
|
|
}
|
|
|
|
s.cache.put(id, res.Audio, res.ContentType)
|
|
|
|
return s.mediaURL(id), nil
|
|
}
|
|
|
|
// Clip returns the cached audio and content type for a media id, if present.
|
|
// Used by the /media/tts/{id} handler.
|
|
func (s *Service) Clip(id string) (audio []byte, contentType string, ok bool) {
|
|
return s.cache.get(id)
|
|
}
|
|
|
|
// applyDefaults fills unset Request fields from config.
|
|
func (s *Service) applyDefaults(req Request) Request {
|
|
if req.Language == "" {
|
|
req.Language = s.cfg.DefaultLanguage
|
|
}
|
|
|
|
if req.Voice == "" {
|
|
req.Voice = s.cfg.DefaultVoice
|
|
}
|
|
|
|
if req.Format == "" {
|
|
req.Format = FormatMP3
|
|
}
|
|
|
|
return req
|
|
}
|
|
|
|
// mediaURL builds the local URL the speaker fetches a cached clip from.
|
|
func (s *Service) mediaURL(id string) string {
|
|
return strings.TrimRight(s.cfg.BaseURL, "/") + "/media/tts/" + id
|
|
}
|
|
|
|
// clipID is a deterministic media id (hash + extension) so identical requests
|
|
// map to the same cached clip and URL.
|
|
func clipID(req Request) string {
|
|
sum := sha256.Sum256([]byte(req.Text + "|" + req.Voice + "|" + req.Language + "|" + req.Format))
|
|
|
|
ext := "mp3"
|
|
if req.Format == FormatWAV {
|
|
ext = "wav"
|
|
}
|
|
|
|
return hex.EncodeToString(sum[:16]) + "." + ext
|
|
}
|