feat(server): warm voice samples at startup
Add warmVoiceSamples() goroutine launched from ListenAndServe.
It waits up to 30s for Kokoro to become reachable, then generates
missing voice sample clips for all available voices and uploads them
to MinIO (_voice-samples/{voice}.mp3). Already-existing samples are
skipped, so the operation is idempotent on restarts.
This commit is contained in:
@@ -188,6 +188,10 @@ func (s *Server) ListenAndServe(ctx context.Context) error {
|
|||||||
|
|
||||||
s.log.Info("HTTP server listening", "addr", s.addr)
|
s.log.Info("HTTP server listening", "addr", s.addr)
|
||||||
|
|
||||||
|
// Pre-populate voice samples in the background so the UI voice selector
|
||||||
|
// has playable previews without requiring a manual trigger.
|
||||||
|
go s.warmVoiceSamples(ctx)
|
||||||
|
|
||||||
select {
|
select {
|
||||||
case <-ctx.Done():
|
case <-ctx.Done():
|
||||||
shutCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
shutCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||||
@@ -1486,6 +1490,78 @@ func voiceSampleKey(voice string) string {
|
|||||||
return fmt.Sprintf("_voice-samples/%s.mp3", safe)
|
return fmt.Sprintf("_voice-samples/%s.mp3", safe)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// warmVoiceSamples runs at startup in a background goroutine.
|
||||||
|
// It generates a short audio sample for every available Kokoro voice that
|
||||||
|
// doesn't already have one in MinIO, so the UI voice selector has playable
|
||||||
|
// previews without requiring a manual trigger.
|
||||||
|
// It respects ctx cancellation and waits up to 30 s for Kokoro to become
|
||||||
|
// reachable before giving up.
|
||||||
|
func (s *Server) warmVoiceSamples(ctx context.Context) {
|
||||||
|
if s.kokoroURL == "" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Wait for Kokoro to be reachable (it may still be starting up).
|
||||||
|
deadline := time.Now().Add(30 * time.Second)
|
||||||
|
for time.Now().Before(deadline) {
|
||||||
|
req, _ := http.NewRequestWithContext(ctx, http.MethodGet, s.kokoroURL+"/v1/audio/voices", nil)
|
||||||
|
resp, err := http.DefaultClient.Do(req)
|
||||||
|
if err == nil {
|
||||||
|
resp.Body.Close()
|
||||||
|
if resp.StatusCode == http.StatusOK {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
return
|
||||||
|
case <-time.After(3 * time.Second):
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
voices := s.voices()
|
||||||
|
s.log.Info("warming voice samples", "voices", len(voices))
|
||||||
|
|
||||||
|
generated, skipped, failed := 0, 0, 0
|
||||||
|
for _, voice := range voices {
|
||||||
|
if ctx.Err() != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
key := voiceSampleKey(voice)
|
||||||
|
if s.store.AudioExists(ctx, key) {
|
||||||
|
skipped++
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
filename, err := s.generateSpeech(ctx, voiceSampleText, voice, 1.0)
|
||||||
|
if err != nil {
|
||||||
|
s.log.Warn("voice sample warmup: generation failed", "voice", voice, "err", err)
|
||||||
|
failed++
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
audioData, err := s.downloadFromKokoro(ctx, filename)
|
||||||
|
if err != nil {
|
||||||
|
s.log.Warn("voice sample warmup: download failed", "voice", voice, "err", err)
|
||||||
|
failed++
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := s.store.PutAudio(ctx, key, audioData); err != nil {
|
||||||
|
s.log.Warn("voice sample warmup: upload failed", "voice", voice, "key", key, "err", err)
|
||||||
|
failed++
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
s.log.Debug("voice sample warmed", "voice", voice)
|
||||||
|
generated++
|
||||||
|
}
|
||||||
|
|
||||||
|
s.log.Info("voice sample warmup complete",
|
||||||
|
"generated", generated, "skipped", skipped, "failed", failed)
|
||||||
|
}
|
||||||
|
|
||||||
// handleGenerateVoiceSamples handles POST /api/audio/voice-samples.
|
// handleGenerateVoiceSamples handles POST /api/audio/voice-samples.
|
||||||
// It generates short audio samples for each available voice and stores them
|
// 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.
|
// in the audio MinIO bucket so the UI can play them during voice selection.
|
||||||
|
|||||||
Reference in New Issue
Block a user