mirror of
https://github.com/gesellix/Bose-SoundTouch.git
synced 2026-08-19 09:06:14 +00:00
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>
110 lines
2.4 KiB
Go
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)
|
|
}
|
|
}
|