Files
Tobias GesellchenandClaude Opus 4.8 c852d07da1 feat(tts): add Google Cloud Text-to-Speech via a pluggable provider
Adds text-to-speech that synthesizes higher-quality audio (Google Cloud
TTS) and plays it on a speaker via the /speaker endpoint. Because Cloud
TTS returns audio bytes (not a fetchable URL), the service caches the
clip and hosts it at GET /media/tts/{id}, mirroring the "ding" endpoint,
then points the speaker at that local URL.

The design is a pluggable Provider interface (pkg/service/tts) wrapping
two modes:
- translate: hands the speaker the (undocumented) Google Translate URL
  directly (no credentials), reusing models.BuildTranslateTTSURL.
- google-cloud: REST API key auth (no SDK/gRPC), bytes cached locally.

Surfaces:
- service: POST /mgmt/tts/speak, GET /mgmt/tts/config, GET /media/tts/{id};
  configured via TTS_PROVIDER / TTS_GOOGLE_API_KEY / TTS_LANGUAGE /
  TTS_VOICE / TTS_APP_KEY / TTS_VOLUME.
- CLI: `soundtouch-cli tts speak` (calls the service with mgmt Basic Auth).
- web: a "TTS" source view (like Play URL / TuneIn), proxied to the
  service via /api/device-speak/{id}.

The /speaker app_key requirement and model limitations still apply; see
docs/content/docs/reference/SPEAKER-ENDPOINT.md.

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

110 lines
2.4 KiB
Go

package tts
import (
"sync"
"time"
)
// clipCache is a small, bounded in-memory store of synthesized audio clips,
// mirroring the in-memory approach the "ding" endpoint uses. Entries expire
// after a TTL; when the cache is full the oldest entry is evicted. This is a
// best-effort cache for recently spoken clips, not durable storage.
type clipCache struct {
mu sync.Mutex
entries map[string]*clipEntry
ttl time.Duration
maxEntries int
now func() time.Time // injectable clock for tests
}
type clipEntry struct {
audio []byte
contentType string
storedAt time.Time
}
// newClipCache returns a cache holding at most maxEntries clips, each valid for
// ttl. Non-positive values fall back to sane defaults.
func newClipCache(ttl time.Duration, maxEntries int) *clipCache {
if ttl <= 0 {
ttl = 10 * time.Minute
}
if maxEntries <= 0 {
maxEntries = 32
}
return &clipCache{
entries: make(map[string]*clipEntry),
ttl: ttl,
maxEntries: maxEntries,
now: time.Now,
}
}
// put stores audio under id, evicting expired entries and, if still over
// capacity, the oldest remaining entry.
func (c *clipCache) put(id string, audio []byte, contentType string) {
c.mu.Lock()
defer c.mu.Unlock()
now := c.now()
c.evictExpiredLocked(now)
c.entries[id] = &clipEntry{
audio: audio,
contentType: contentType,
storedAt: now,
}
for len(c.entries) > c.maxEntries {
c.evictOldestLocked()
}
}
// get returns the audio and content type for id if present and not expired.
func (c *clipCache) get(id string) (audio []byte, contentType string, ok bool) {
c.mu.Lock()
defer c.mu.Unlock()
entry, found := c.entries[id]
if !found {
return nil, "", false
}
if c.now().Sub(entry.storedAt) > c.ttl {
delete(c.entries, id)
return nil, "", false
}
return entry.audio, entry.contentType, true
}
// evictExpiredLocked removes all entries older than the TTL. Caller holds mu.
func (c *clipCache) evictExpiredLocked(now time.Time) {
for id, entry := range c.entries {
if now.Sub(entry.storedAt) > c.ttl {
delete(c.entries, id)
}
}
}
// evictOldestLocked removes the single oldest entry. Caller holds mu.
func (c *clipCache) evictOldestLocked() {
var (
oldestID string
oldestAt time.Time
found bool
)
for id, entry := range c.entries {
if !found || entry.storedAt.Before(oldestAt) {
oldestID, oldestAt, found = id, entry.storedAt, true
}
}
if found {
delete(c.entries, oldestID)
}
}