feat(audio): add voice selector, voice samples, MediaSession cover art, and fix speed/voice bug
- Fix speed/voice bug: AudioPlayer no longer accepts speed/voice as props;
startPlayback() reads audioStore.voice/speed directly instead of overwriting them
- Add GET /api/voices endpoint (Go) proxying Kokoro, cached in-memory
- Add POST /api/audio/voice-samples endpoint (Go) to pre-generate sample clips
for all voices and store them in MinIO under _voice-samples/{voice}.mp3
- Add GET /api/presign/voice-sample/{voice} endpoint (Go)
- Add SvelteKit proxy routes: /api/voices, /api/presign/voice-sample, /api/audio/voice-samples
- Add presignVoiceSample() helper in minio.ts with proper host rewrite
- Pass book.cover through +page.server.ts -> +page.svelte -> AudioPlayer
- Set navigator.mediaSession.metadata on playback start so cover art,
book title, and chapter title appear on phone lock screen / notification
This commit is contained in:
@@ -151,8 +151,19 @@ func (s *Server) ListenAndServe(ctx context.Context) error {
|
||||
// Presigned URL API (for SvelteKit UI)
|
||||
mux.HandleFunc("GET /api/presign/chapter/{slug}/{n}", s.handlePresignChapter)
|
||||
mux.HandleFunc("GET /api/presign/audio/{slug}/{n}", s.handlePresignAudio)
|
||||
mux.HandleFunc("GET /api/presign/voice-sample/{voice}", s.handlePresignVoiceSample)
|
||||
// Plain-text chapter content (used server-side for audio generation)
|
||||
mux.HandleFunc("GET /api/chapter-text/{slug}/{n}", s.handleChapterText)
|
||||
// Voices list (proxied from Kokoro)
|
||||
mux.HandleFunc("GET /api/voices", s.handleVoices)
|
||||
// Voice sample generation — generates a short audio clip for each voice
|
||||
// and stores it in MinIO for UI preview playback.
|
||||
voiceSampleHandler := http.TimeoutHandler(
|
||||
http.HandlerFunc(s.handleGenerateVoiceSamples),
|
||||
15*time.Minute,
|
||||
`{"error":"voice sample generation timed out"}`,
|
||||
)
|
||||
mux.Handle("POST /api/audio/voice-samples", voiceSampleHandler)
|
||||
// Server-side audio generation via Kokoro /v1/audio/speech.
|
||||
// Generation can take several minutes, so wrap in its own timeout handler.
|
||||
audioGenHandler := http.TimeoutHandler(
|
||||
@@ -1446,3 +1457,141 @@ func (s *Server) handleReindex(w http.ResponseWriter, r *http.Request) {
|
||||
"indexed": count,
|
||||
})
|
||||
}
|
||||
|
||||
// ─── Voices API ───────────────────────────────────────────────────────────────
|
||||
|
||||
// handleVoices handles GET /api/voices.
|
||||
// Returns the list of available Kokoro voices as JSON: {"voices": [...]}
|
||||
func (s *Server) handleVoices(w http.ResponseWriter, _ *http.Request) {
|
||||
voices := s.voices()
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_ = json.NewEncoder(w).Encode(map[string]interface{}{"voices": voices})
|
||||
}
|
||||
|
||||
// ─── Voice sample generation ──────────────────────────────────────────────────
|
||||
|
||||
// voiceSampleText is the short passage used for voice sample previews.
|
||||
const voiceSampleText = "The ancient library held secrets older than memory itself, its dust-laden shelves stretching upward into shadow. She reached for the worn leather spine, fingers trembling with anticipation."
|
||||
|
||||
// voiceSampleKey returns the MinIO object key for a voice sample.
|
||||
// Key: _voice-samples/{voice}.mp3
|
||||
func voiceSampleKey(voice string) string {
|
||||
safe := strings.Map(func(r rune) rune {
|
||||
if (r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z') ||
|
||||
(r >= '0' && r <= '9') || r == '_' || r == '-' {
|
||||
return r
|
||||
}
|
||||
return '_'
|
||||
}, voice)
|
||||
return fmt.Sprintf("_voice-samples/%s.mp3", safe)
|
||||
}
|
||||
|
||||
// handleGenerateVoiceSamples handles POST /api/audio/voice-samples.
|
||||
// It generates short audio samples for each available voice and stores them
|
||||
// in the audio MinIO bucket so the UI can play them during voice selection.
|
||||
// Already-generated samples are skipped (idempotent).
|
||||
// Optional JSON body: {"voices": ["af_bella", ...]} to generate a subset.
|
||||
// Returns: {"generated": [...], "skipped": [...], "failed": [...]}
|
||||
func (s *Server) handleGenerateVoiceSamples(w http.ResponseWriter, r *http.Request) {
|
||||
if s.kokoroURL == "" {
|
||||
http.Error(w, `{"error":"kokoro not configured"}`, http.StatusServiceUnavailable)
|
||||
return
|
||||
}
|
||||
|
||||
// Parse optional voice list from body.
|
||||
var body struct {
|
||||
Voices []string `json:"voices"`
|
||||
}
|
||||
if r.Body != nil {
|
||||
_ = json.NewDecoder(r.Body).Decode(&body)
|
||||
}
|
||||
|
||||
targetVoices := body.Voices
|
||||
if len(targetVoices) == 0 {
|
||||
targetVoices = s.voices()
|
||||
}
|
||||
|
||||
type result struct {
|
||||
Generated []string `json:"generated"`
|
||||
Skipped []string `json:"skipped"`
|
||||
Failed []string `json:"failed"`
|
||||
}
|
||||
var res result
|
||||
|
||||
for _, voice := range targetVoices {
|
||||
key := voiceSampleKey(voice)
|
||||
|
||||
// Skip if already uploaded.
|
||||
if s.store.AudioExists(r.Context(), key) {
|
||||
res.Skipped = append(res.Skipped, voice)
|
||||
s.log.Debug("voice sample already exists, skipping", "voice", voice)
|
||||
continue
|
||||
}
|
||||
|
||||
// Generate via Kokoro (speed 1.0 for samples).
|
||||
filename, err := s.generateSpeech(r.Context(), voiceSampleText, voice, 1.0)
|
||||
if err != nil {
|
||||
s.log.Warn("voice sample generation failed", "voice", voice, "err", err)
|
||||
res.Failed = append(res.Failed, voice)
|
||||
continue
|
||||
}
|
||||
|
||||
// Download from Kokoro and upload to MinIO.
|
||||
audioData, dlErr := s.downloadFromKokoro(r.Context(), filename)
|
||||
if dlErr != nil {
|
||||
s.log.Warn("voice sample kokoro download failed", "voice", voice, "err", dlErr)
|
||||
res.Failed = append(res.Failed, voice)
|
||||
continue
|
||||
}
|
||||
|
||||
if putErr := s.store.PutAudio(r.Context(), key, audioData); putErr != nil {
|
||||
s.log.Warn("voice sample MinIO upload failed", "voice", voice, "key", key, "err", putErr)
|
||||
res.Failed = append(res.Failed, voice)
|
||||
continue
|
||||
}
|
||||
|
||||
s.log.Info("voice sample generated", "voice", voice, "key", key)
|
||||
res.Generated = append(res.Generated, voice)
|
||||
}
|
||||
|
||||
if res.Generated == nil {
|
||||
res.Generated = []string{}
|
||||
}
|
||||
if res.Skipped == nil {
|
||||
res.Skipped = []string{}
|
||||
}
|
||||
if res.Failed == nil {
|
||||
res.Failed = []string{}
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_ = json.NewEncoder(w).Encode(res)
|
||||
}
|
||||
|
||||
// handlePresignVoiceSample handles GET /api/presign/voice-sample/{voice}.
|
||||
// Returns a presigned URL for the voice sample audio file stored in MinIO.
|
||||
// Returns 404 if the sample has not been generated yet.
|
||||
func (s *Server) handlePresignVoiceSample(w http.ResponseWriter, r *http.Request) {
|
||||
voice := r.PathValue("voice")
|
||||
if voice == "" {
|
||||
http.Error(w, `{"error":"missing voice"}`, http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
key := voiceSampleKey(voice)
|
||||
|
||||
if !s.store.AudioExists(r.Context(), key) {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
url, err := s.store.PresignAudio(r.Context(), key, 1*time.Hour)
|
||||
if err != nil {
|
||||
s.log.Error("presign voice sample failed", "voice", voice, "err", err)
|
||||
http.Error(w, `{"error":"presign failed"}`, http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_ = json.NewEncoder(w).Encode(map[string]string{"url": url})
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user