From c8e0cf28131ef82553efa480fa24619666c1640b Mon Sep 17 00:00:00 2001 From: Admin Date: Wed, 4 Mar 2026 19:10:48 +0500 Subject: [PATCH] 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. --- scraper/internal/server/server.go | 76 +++++++++++++++++++++++++++++++ 1 file changed, 76 insertions(+) diff --git a/scraper/internal/server/server.go b/scraper/internal/server/server.go index f080120..c466c77 100644 --- a/scraper/internal/server/server.go +++ b/scraper/internal/server/server.go @@ -188,6 +188,10 @@ func (s *Server) ListenAndServe(ctx context.Context) error { 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 { case <-ctx.Done(): 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) } +// 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. // 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.