Files
Tobias GesellchenandClaude Opus 4.8 56c4ae4e2d fix(tts): play via LOCAL_INTERNET_RADIO; accept app_key at /v1/auth
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>
2026-05-31 22:35:31 +02:00

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
}