diff --git a/backend/internal/backend/handlers.go b/backend/internal/backend/handlers.go index 789a788..7ad7509 100644 --- a/backend/internal/backend/handlers.go +++ b/backend/internal/backend/handlers.go @@ -904,6 +904,115 @@ func (s *Server) handleAudioStream(w http.ResponseWriter, r *http.Request) { // on its next poll as soon as the MinIO object is present. } +// handleAudioPreview handles GET /api/audio-preview/{slug}/{n}. +// +// CF AI voices are batch-only and can take 1-2+ minutes to generate a full +// chapter. This endpoint generates only the FIRST chunk of text (~1 800 chars, +// roughly 1-2 minutes of audio) so the client can start playing immediately +// while the full audio is generated in the background by the runner. +// +// Fast path: if a preview object already exists in MinIO, redirects to its +// presigned URL (no regeneration). +// +// Slow path: generates the first chunk via CF AI, streams the MP3 bytes to the +// client, and simultaneously uploads to MinIO under a "_preview" key so future +// requests hit the fast path. +// +// Only CF AI voices are expected here. Calling this with a Kokoro/PocketTTS +// voice falls back to the normal audio-stream endpoint behaviour. +// +// Query params: +// +// voice (required — must be a cfai: voice) +func (s *Server) handleAudioPreview(w http.ResponseWriter, r *http.Request) { + slug := r.PathValue("slug") + n, err := strconv.Atoi(r.PathValue("n")) + if err != nil || n < 1 { + jsonError(w, http.StatusBadRequest, "invalid chapter") + return + } + + voice := r.URL.Query().Get("voice") + if voice == "" { + voice = s.cfg.DefaultVoice + } + + if s.deps.CFAI == nil { + jsonError(w, http.StatusServiceUnavailable, "cloudflare AI TTS not configured") + return + } + + // Preview key: same as normal key with a "_preview" suffix before the extension. + // e.g. slug/1/cfai:luna_preview.mp3 + previewKey := s.deps.AudioStore.AudioObjectKeyExt(slug, n, voice+"_preview", "mp3") + + // ── Fast path: preview already in MinIO ────────────────────────────────── + if s.deps.AudioStore.AudioExists(r.Context(), previewKey) { + presignURL, err := s.deps.PresignStore.PresignAudio(r.Context(), previewKey, 1*time.Hour) + if err != nil { + s.deps.Log.Error("handleAudioPreview: PresignAudio failed", "slug", slug, "n", n, "err", err) + jsonError(w, http.StatusInternalServerError, "presign failed") + return + } + http.Redirect(w, r, presignURL, http.StatusFound) + return + } + + // ── Slow path: generate first chunk + stream + save ────────────────────── + + // Read the chapter text. + raw, err := s.deps.BookReader.ReadChapter(r.Context(), slug, n) + if err != nil { + s.deps.Log.Error("handleAudioPreview: ReadChapter failed", "slug", slug, "n", n, "err", err) + jsonError(w, http.StatusNotFound, "chapter not found") + return + } + text := stripMarkdown(raw) + if text == "" { + jsonError(w, http.StatusUnprocessableEntity, "chapter text is empty") + return + } + + // Take only the first ~1 800 characters — one CF AI chunk, roughly 1-2 min. + const previewChars = 1800 + firstChunk := text + if len([]rune(text)) > previewChars { + runes := []rune(text) + firstChunk = string(runes[:previewChars]) + // Walk back to last sentence boundary (. ! ?) to avoid a mid-word cut. + for i := previewChars - 1; i > previewChars/2; i-- { + r := runes[i] + if r == '.' || r == '!' || r == '?' || r == '\n' { + firstChunk = string(runes[:i+1]) + break + } + } + } + + // Generate the preview chunk via CF AI. + mp3, err := s.deps.CFAI.GenerateAudio(r.Context(), firstChunk, voice) + if err != nil { + s.deps.Log.Error("handleAudioPreview: GenerateAudio failed", "slug", slug, "n", n, "voice", voice, "err", err) + jsonError(w, http.StatusInternalServerError, "tts generation failed") + return + } + + // Upload to MinIO in the background so the next request hits the fast path. + go func() { + if uploadErr := s.deps.AudioStore.PutAudio( + context.Background(), previewKey, mp3, + ); uploadErr != nil { + s.deps.Log.Error("handleAudioPreview: MinIO upload failed", "key", previewKey, "err", uploadErr) + } + }() + + w.Header().Set("Content-Type", "audio/mpeg") + w.Header().Set("Content-Length", strconv.Itoa(len(mp3))) + w.Header().Set("Cache-Control", "no-store") + w.WriteHeader(http.StatusOK) + _, _ = w.Write(mp3) +} + // ── Translation ──────────────────────────────────────────────────────────────── // supportedTranslationLangs is the set of target locales the backend accepts. diff --git a/backend/internal/backend/server.go b/backend/internal/backend/server.go index efab799..dc35b7f 100644 --- a/backend/internal/backend/server.go +++ b/backend/internal/backend/server.go @@ -180,6 +180,9 @@ func (s *Server) ListenAndServe(ctx context.Context) error { // Streaming audio: serves from MinIO if cached, else streams live TTS // while simultaneously uploading to MinIO for future requests. mux.HandleFunc("GET /api/audio-stream/{slug}/{n}", s.handleAudioStream) + // CF AI preview: generates only the first ~1 800-char chunk so the client + // can start playing immediately while the full audio is generated by the runner. + mux.HandleFunc("GET /api/audio-preview/{slug}/{n}", s.handleAudioPreview) // Translation task creation (backend creates task; runner executes via LibreTranslate) mux.HandleFunc("POST /api/translation/{slug}/{n}", s.handleTranslationGenerate) diff --git a/ui/src/lib/audio.svelte.ts b/ui/src/lib/audio.svelte.ts index c0da47a..bd28b61 100644 --- a/ui/src/lib/audio.svelte.ts +++ b/ui/src/lib/audio.svelte.ts @@ -62,6 +62,13 @@ class AudioStore { /** Pseudo-progress bar value 0–100 during generation */ progress = $state(0); + /** + * True while playing a short CF AI preview clip (~1-2 min) and the full + * audio is still being generated in the background. Set to false once the + * full audio URL has been swapped in. + */ + isPreview = $state(false); + // ── Playback state (kept in sync with the