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.