diff --git a/scraper/internal/server/server.go b/scraper/internal/server/server.go index 483f945..f080120 100644 --- a/scraper/internal/server/server.go +++ b/scraper/internal/server/server.go @@ -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}) +} diff --git a/ui/src/lib/components/AudioPlayer.svelte b/ui/src/lib/components/AudioPlayer.svelte index bb51f62..74f59e8 100644 --- a/ui/src/lib/components/AudioPlayer.svelte +++ b/ui/src/lib/components/AudioPlayer.svelte @@ -15,6 +15,16 @@ * 4. If 404, POST /api/audio/:slug/:n to generate. Drive pseudo progress bar. * On success, presign again and set audioUrl. * + * ── Voice selection ────────────────────────────────────────────────────── + * A "Change voice" panel lets users pick from the available Kokoro voices. + * Each voice shows a play button that streams a pre-generated sample from + * MinIO (GET /api/presign/voice-sample?voice=...). Samples are generated + * server-side via POST /api/audio/voice-samples. + * + * Changing voice updates audioStore.voice (saved to settings via layout). + * The currently loaded chapter audio is NOT re-generated automatically — + * the new voice takes effect on next "Play narration" click. + * * ── Pre-fetch (immediate + 90% fallback) ──────────────────────────────── * When autoNext is on, prefetchNext() is called as soon as the current * chapter starts playing (via maybeStartPrefetch() at the end of @@ -45,10 +55,12 @@ chapter: number; chapterTitle?: string; bookTitle?: string; + /** Cover image URL for the book (used in MediaSession for lock-screen art). */ + cover?: string; /** Next chapter number, or null/undefined if this is the last chapter. */ nextChapter?: number | null; - voice?: string; - speed?: number; + /** List of available voices from the Kokoro API. */ + voices?: string[]; } let { @@ -56,11 +68,107 @@ chapter, chapterTitle = '', bookTitle = '', + cover = '', nextChapter = null, - voice = 'af_bella', - speed = 1.0 + voices = [] }: Props = $props(); + // ── Voice selector state ──────────────────────────────────────────────── + let showVoicePanel = $state(false); + /** Voice whose sample is currently being fetched or playing. */ + let samplePlayingVoice = $state(null); + /** Currently active sample