Add async audio generation: job tracking in PocketBase + UI polling
Some checks failed
Deploy / Deploy Production (push) Has been skipped
Deploy / Cleanup Preview (push) Has been skipped
Deploy / Deploy Preview (push) Failing after 1s
CI / UI / Build (pull_request) Failing after 7s
CI / Scraper / Lint (pull_request) Failing after 8s
CI / Scraper / Test (pull_request) Failing after 9s
CI / Scraper / Build (pull_request) Has been skipped
iOS CI / Build (pull_request) Failing after 2s
iOS CI / Test (pull_request) Has been skipped

Replace blocking POST /api/audio with a non-blocking 202 flow: the Go
handler immediately enqueues a job in a new `audio_jobs` PocketBase
collection and returns {job_id, status}. A background goroutine runs
the actual Kokoro TTS work and updates job status (pending → generating
→ done/failed). A new GET /api/audio/status/{slug}/{n} endpoint lets
clients poll progress. The SvelteKit proxy and AudioPlayer.svelte are
updated to POST, then poll the status route every 2s until done.
This commit is contained in:
Admin
2026-03-07 20:12:08 +05:00
parent 88644341d8
commit 89f0dfb113
9 changed files with 540 additions and 83 deletions

View File

@@ -16,17 +16,15 @@ import (
//
// handleAudioGenerate handles POST /api/audio/{slug}/{n}.
//
// It calls Kokoro's POST /v1/audio/speech with return_download_link=true.
// Kokoro generates the audio, saves it to its own temp storage, and returns
// the download filename in the X-Download-Path response header.
// We cache that filename (in memory, keyed by slug/chapter/voice) and
// return a proxy URL that the browser sets as audio.src.
// The handler is non-blocking: it creates an audio_jobs record in PocketBase
// with status="pending", then fires a background goroutine to call Kokoro.
// The caller should poll GET /api/audio/status/{slug}/{n} to track progress.
//
// TTS is always generated at speed 1.0; playback speed is controlled
// client-side via the <audio> element's playbackRate.
// If audio is already cached (audio_cache hit) the handler returns
// status=200 with the proxy URL immediately — no job is created.
//
// On a cache hit the proxy URL is returned immediately without re-generating.
// Concurrent requests for the same key are deduplicated.
// Concurrent requests for the same key are deduplicated via audioJobIDs:
// the second caller gets a 202 with the existing job_id immediately.
func (s *Server) handleAudioGenerate(w http.ResponseWriter, r *http.Request) {
slug := r.PathValue("slug")
n, err := strconv.Atoi(r.PathValue("n"))
@@ -35,8 +33,7 @@ func (s *Server) handleAudioGenerate(w http.ResponseWriter, r *http.Request) {
return
}
// Parse optional voice from JSON body. Speed is intentionally ignored —
// TTS is always generated at 1.0; playback speed is applied client-side.
// Parse optional voice from JSON body.
voice := s.kokoroVoice
var body struct {
Voice string `json:"voice"`
@@ -58,84 +55,197 @@ func (s *Server) handleAudioGenerate(w http.ResponseWriter, r *http.Request) {
}
// Deduplicate concurrent generation for the same key.
// If a goroutine is already running for this key, return the existing job_id.
s.audioMu.Lock()
if ch, ok := s.audioInFlight[cacheKey]; ok {
if jobID, ok := s.audioJobIDs[cacheKey]; ok {
s.audioMu.Unlock()
select {
case <-ch:
case <-r.Context().Done():
http.Error(w, `{"error":"request cancelled"}`, http.StatusServiceUnavailable)
return
}
// Check store again after waiting.
if filename, ok := s.store.GetAudioCache(r.Context(), cacheKey); ok {
s.writeAudioResponse(w, slug, n, voice, filename)
} else {
http.Error(w, `{"error":"audio generation failed"}`, http.StatusInternalServerError)
}
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusAccepted)
_ = json.NewEncoder(w).Encode(map[string]string{"job_id": jobID, "status": "generating"})
return
}
ch := make(chan struct{})
s.audioInFlight[cacheKey] = ch
// Create the PocketBase job record.
jobID, createErr := s.store.CreateAudioJob(r.Context(), slug, n, voice)
if createErr != nil {
s.audioMu.Unlock()
s.log.Warn("audio: failed to create job record", "slug", slug, "chapter", n, "err", createErr)
// Non-fatal: still proceed, just won't have a persistent job record.
jobID = ""
}
s.audioJobIDs[cacheKey] = jobID
s.audioMu.Unlock()
defer func() {
s.audioMu.Lock()
delete(s.audioInFlight, cacheKey)
s.audioMu.Unlock()
close(ch)
// Fire background goroutine — request context must NOT be used here since
// the handler returns immediately.
maxChars := body.MaxChars
go func() {
defer func() {
s.audioMu.Lock()
delete(s.audioJobIDs, cacheKey)
s.audioMu.Unlock()
}()
bgCtx, cancel := context.WithTimeout(context.Background(), 10*time.Minute)
defer cancel()
s.runAudioGeneration(bgCtx, jobID, slug, n, voice, maxChars, cacheKey)
}()
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusAccepted)
_ = json.NewEncoder(w).Encode(map[string]string{"job_id": jobID, "status": "pending"})
}
// runAudioGeneration performs the actual Kokoro TTS work in a goroutine.
// It updates the audio_jobs record as it progresses and writes to audio_cache
// and MinIO on success.
func (s *Server) runAudioGeneration(ctx context.Context, jobID, slug string, n int, voice string, maxChars int, cacheKey string) {
markFailed := func(msg string) {
if jobID == "" {
return
}
if err := s.store.UpdateAudioJob(ctx, jobID, "failed", msg, time.Now()); err != nil {
s.log.Warn("audio: failed to update job to failed", "job_id", jobID, "err", err)
}
}
// Transition to "generating".
if jobID != "" {
if err := s.store.UpdateAudioJob(ctx, jobID, "generating", "", time.Time{}); err != nil {
s.log.Warn("audio: failed to mark job generating", "job_id", jobID, "err", err)
}
}
// Load and validate chapter text.
raw, err := s.store.ReadChapter(r.Context(), slug, n)
raw, err := s.store.ReadChapter(ctx, slug, n)
if err != nil {
http.Error(w, `{"error":"chapter not found"}`, http.StatusNotFound)
s.log.Error("audio: chapter not found", "slug", slug, "chapter", n, "err", err)
markFailed("chapter not found")
return
}
text := stripMarkdown(raw)
if text == "" {
http.Error(w, `{"error":"chapter text is empty"}`, http.StatusUnprocessableEntity)
markFailed("chapter text is empty")
return
}
if body.MaxChars > 0 && len([]rune(text)) > body.MaxChars {
text = string([]rune(text)[:body.MaxChars])
if maxChars > 0 && len([]rune(text)) > maxChars {
text = string([]rune(text)[:maxChars])
}
if s.kokoroURL == "" {
http.Error(w, `{"error":"kokoro not configured"}`, http.StatusServiceUnavailable)
markFailed("kokoro not configured")
return
}
// Call Kokoro POST /v1/audio/speech at speed 1.0.
// Kokoro saves the generated audio to its own temp storage and returns the
// download path in the X-Download-Path response header.
filename, err := s.generateSpeech(r.Context(), text, voice, 1.0)
// Call Kokoro.
filename, err := s.generateSpeech(ctx, text, voice, 1.0)
if err != nil {
s.log.Error("kokoro speech generation failed", "slug", slug, "chapter", n, "err", err)
http.Error(w, `{"error":"speech generation failed"}`, http.StatusBadGateway)
s.log.Error("audio: kokoro speech generation failed", "slug", slug, "chapter", n, "err", err)
markFailed(err.Error())
return
}
if err := s.store.SetAudioCache(r.Context(), cacheKey, filename); err != nil {
s.log.Warn("audio cache write failed", "slug", slug, "chapter", n, "cache_key", cacheKey, "err", err)
if err := s.store.SetAudioCache(ctx, cacheKey, filename); err != nil {
s.log.Warn("audio: cache write failed", "slug", slug, "chapter", n, "err", err)
}
// Download generated audio from Kokoro and persist to MinIO synchronously
// so that the presigned URL returned to the client is immediately valid.
// Download from Kokoro and persist to MinIO.
minioKey := s.store.AudioObjectKey(slug, n, voice)
audioData, dlErr := s.downloadFromKokoro(r.Context(), filename)
audioData, dlErr := s.downloadFromKokoro(ctx, filename)
if dlErr != nil {
s.log.Warn("audio MinIO upload skipped: kokoro download failed",
s.log.Warn("audio: MinIO upload skipped: kokoro download failed",
"slug", slug, "chapter", n, "filename", filename, "err", dlErr)
} else if putErr := s.store.PutAudio(r.Context(), minioKey, audioData); putErr != nil {
s.log.Warn("audio MinIO upload failed",
} else if putErr := s.store.PutAudio(ctx, minioKey, audioData); putErr != nil {
s.log.Warn("audio: MinIO upload failed",
"slug", slug, "chapter", n, "key", minioKey, "err", putErr)
// upload failure is non-fatal; the client can still stream via Kokoro proxy
} else {
s.log.Info("audio uploaded to MinIO", "slug", slug, "chapter", n, "key", minioKey)
s.log.Info("audio: uploaded to MinIO", "slug", slug, "chapter", n, "key", minioKey)
}
s.log.Info("audio generated", "slug", slug, "chapter", n, "filename", filename)
s.writeAudioResponse(w, slug, n, voice, filename)
// Mark job done.
if jobID != "" {
if err := s.store.UpdateAudioJob(ctx, jobID, "done", "", time.Now()); err != nil {
s.log.Warn("audio: failed to mark job done", "job_id", jobID, "err", err)
}
}
s.log.Info("audio: generation complete", "slug", slug, "chapter", n, "filename", filename)
}
// handleAudioStatus handles GET /api/audio/status/{slug}/{n}.
// Returns the current generation status for the given chapter + voice.
//
// Query params: voice (optional, defaults to server default).
//
// Possible responses:
// - 200 {"status":"done","url":"/api/audio-proxy/..."} — audio ready
// - 200 {"status":"pending"|"generating","job_id":"..."} — in progress
// - 200 {"status":"idle"} — no job yet
// - 200 {"status":"failed","error":"..."} — last job failed
func (s *Server) handleAudioStatus(w http.ResponseWriter, r *http.Request) {
slug := r.PathValue("slug")
n, err := strconv.Atoi(r.PathValue("n"))
if err != nil || n < 1 || slug == "" {
http.Error(w, `{"error":"invalid params"}`, http.StatusBadRequest)
return
}
voice := r.URL.Query().Get("voice")
if voice == "" {
voice = s.kokoroVoice
}
cacheKey := fmt.Sprintf("%s/%d/%s", slug, n, voice)
w.Header().Set("Content-Type", "application/json")
// Fast path: audio already in audio_cache → done.
if filename, ok := s.store.GetAudioCache(r.Context(), cacheKey); ok {
proxyURL := fmt.Sprintf("/api/audio-proxy/%s/%d?voice=%s", slug, n, voice)
_ = json.NewEncoder(w).Encode(map[string]string{
"status": "done",
"url": proxyURL,
"filename": filename,
})
return
}
// Check in-flight map for live job ID.
s.audioMu.Lock()
liveJobID, inFlight := s.audioJobIDs[cacheKey]
s.audioMu.Unlock()
if inFlight {
// Look up persistent record for richer status.
if job, ok, _ := s.store.GetAudioJob(r.Context(), cacheKey); ok {
_ = json.NewEncoder(w).Encode(map[string]string{
"status": job.Status,
"job_id": liveJobID,
})
return
}
_ = json.NewEncoder(w).Encode(map[string]string{
"status": "generating",
"job_id": liveJobID,
})
return
}
// Not in-flight: check persistent record for last known result.
job, ok, _ := s.store.GetAudioJob(r.Context(), cacheKey)
if !ok {
_ = json.NewEncoder(w).Encode(map[string]string{"status": "idle"})
return
}
resp := map[string]string{
"status": job.Status,
"job_id": job.ID,
}
if job.Status == "failed" && job.ErrorMessage != "" {
resp["error"] = job.ErrorMessage
}
_ = json.NewEncoder(w).Encode(resp)
}
// generateSpeech calls POST /v1/audio/speech on Kokoro with return_download_link=true
@@ -210,7 +320,7 @@ func (s *Server) downloadFromKokoro(ctx context.Context, filename string) ([]byt
return data, nil
}
// writeAudioResponse writes the JSON response for a generated audio chapter.
// writeAudioResponse writes the JSON response for an already-cached audio chapter.
// The URL points to our proxy handler GET /api/audio-proxy/{slug}/{n}.
func (s *Server) writeAudioResponse(w http.ResponseWriter, slug string, n int, voice string, filename string) {
proxyURL := fmt.Sprintf("/api/audio-proxy/%s/%d?voice=%s", slug, n, voice)

View File

@@ -11,7 +11,8 @@
// GET /api/presign/chapter/{slug}/{n} — presigned MinIO URL for chapter markdown
// GET /api/presign/audio/{slug}/{n} — presigned MinIO URL for chapter audio
// GET /api/chapter-text/{slug}/{n} — plain text of chapter (markdown stripped)
// POST /api/audio/{slug}/{n} — trigger Kokoro audio generation
// POST /api/audio/{slug}/{n} — trigger Kokoro audio generation (async, returns 202)
// GET /api/audio/status/{slug}/{n} — poll audio generation job status
// GET /api/audio-proxy/{slug}/{n} — proxy generated audio from Kokoro
package server
@@ -47,11 +48,11 @@ type Server struct {
voiceMu sync.RWMutex
cachedVoices []string // populated on first request from Kokoro /v1/audio/voices
// audioMu guards audioInFlight only.
// audioMu guards audioJobIDs only.
// Completed audio filenames are persisted to the Store (PocketBase).
// audioInFlight deduplicates concurrent generation requests for the same key.
audioMu sync.Mutex
audioInFlight map[string]chan struct{} // cacheKey → closed when done
// audioJobIDs deduplicates concurrent generation requests for the same key.
audioMu sync.Mutex
audioJobIDs map[string]string // cacheKey → PocketBase job ID (empty string if record creation failed)
// browseMu guards browseInFlight — keys currently being refreshed
// in the background.
@@ -82,7 +83,7 @@ func New(addr string, oCfg orchestrator.Config, novel scraper.NovelScraper, log
store: store,
kokoroURL: kokoroURL,
kokoroVoice: kokoroVoice,
audioInFlight: make(map[string]chan struct{}),
audioJobIDs: make(map[string]string),
browseInFlight: make(map[string]struct{}),
browseMemCache: make(map[string]browseCacheEntry),
}
@@ -177,13 +178,11 @@ func (s *Server) ListenAndServe(ctx context.Context) error {
)
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(
http.HandlerFunc(s.handleAudioGenerate),
10*time.Minute,
`{"error":"audio generation timed out"}`,
)
mux.Handle("POST /api/audio/{slug}/{n}", audioGenHandler)
// POST returns 202 immediately and starts a background goroutine;
// poll GET /api/audio/status/{slug}/{n} to track progress.
mux.HandleFunc("POST /api/audio/{slug}/{n}", s.handleAudioGenerate)
// Audio job status polling endpoint.
mux.HandleFunc("GET /api/audio/status/{slug}/{n}", s.handleAudioStatus)
// Proxy route: fetches the generated file from Kokoro /v1/download/{filename}.
mux.HandleFunc("GET /api/audio-proxy/{slug}/{n}", s.handleAudioProxy)