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.
652 lines
21 KiB
Go
652 lines
21 KiB
Go
package server
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"io"
|
|
"net/http"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
)
|
|
|
|
// ─── Audio generation via Kokoro /v1/audio/speech ────────────────────────────
|
|
//
|
|
// handleAudioGenerate handles POST /api/audio/{slug}/{n}.
|
|
//
|
|
// 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.
|
|
//
|
|
// If audio is already cached (audio_cache hit) the handler returns
|
|
// status=200 with the proxy URL immediately — no job is created.
|
|
//
|
|
// 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"))
|
|
if err != nil || n < 1 {
|
|
http.Error(w, `{"error":"invalid chapter"}`, http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
// Parse optional voice from JSON body.
|
|
voice := s.kokoroVoice
|
|
var body struct {
|
|
Voice string `json:"voice"`
|
|
MaxChars int `json:"max_chars"`
|
|
}
|
|
if r.Body != nil {
|
|
_ = json.NewDecoder(r.Body).Decode(&body)
|
|
}
|
|
if body.Voice != "" {
|
|
voice = body.Voice
|
|
}
|
|
|
|
cacheKey := fmt.Sprintf("%s/%d/%s", slug, n, voice)
|
|
|
|
// Fast path: already generated (check persistent store first).
|
|
if filename, ok := s.store.GetAudioCache(r.Context(), cacheKey); ok {
|
|
s.writeAudioResponse(w, slug, n, voice, filename)
|
|
return
|
|
}
|
|
|
|
// 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 jobID, ok := s.audioJobIDs[cacheKey]; ok {
|
|
s.audioMu.Unlock()
|
|
w.Header().Set("Content-Type", "application/json")
|
|
w.WriteHeader(http.StatusAccepted)
|
|
_ = json.NewEncoder(w).Encode(map[string]string{"job_id": jobID, "status": "generating"})
|
|
return
|
|
}
|
|
|
|
// 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()
|
|
|
|
// 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(ctx, slug, n)
|
|
if err != nil {
|
|
s.log.Error("audio: chapter not found", "slug", slug, "chapter", n, "err", err)
|
|
markFailed("chapter not found")
|
|
return
|
|
}
|
|
text := stripMarkdown(raw)
|
|
if text == "" {
|
|
markFailed("chapter text is empty")
|
|
return
|
|
}
|
|
if maxChars > 0 && len([]rune(text)) > maxChars {
|
|
text = string([]rune(text)[:maxChars])
|
|
}
|
|
if s.kokoroURL == "" {
|
|
markFailed("kokoro not configured")
|
|
return
|
|
}
|
|
|
|
// Call Kokoro.
|
|
filename, err := s.generateSpeech(ctx, text, voice, 1.0)
|
|
if err != nil {
|
|
s.log.Error("audio: kokoro speech generation failed", "slug", slug, "chapter", n, "err", err)
|
|
markFailed(err.Error())
|
|
return
|
|
}
|
|
|
|
if err := s.store.SetAudioCache(ctx, cacheKey, filename); err != nil {
|
|
s.log.Warn("audio: cache write failed", "slug", slug, "chapter", n, "err", err)
|
|
}
|
|
|
|
// Download from Kokoro and persist to MinIO.
|
|
minioKey := s.store.AudioObjectKey(slug, n, voice)
|
|
audioData, dlErr := s.downloadFromKokoro(ctx, filename)
|
|
if dlErr != nil {
|
|
s.log.Warn("audio: MinIO upload skipped: kokoro download failed",
|
|
"slug", slug, "chapter", n, "filename", filename, "err", dlErr)
|
|
} 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)
|
|
} else {
|
|
s.log.Info("audio: uploaded to MinIO", "slug", slug, "chapter", n, "key", minioKey)
|
|
}
|
|
|
|
// 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
|
|
// and returns the filename from the X-Download-Path response header.
|
|
func (s *Server) generateSpeech(ctx context.Context, text, voice string, speed float64) (string, error) {
|
|
reqBody, _ := json.Marshal(map[string]interface{}{
|
|
"model": "kokoro",
|
|
"input": text,
|
|
"voice": voice,
|
|
"response_format": "mp3",
|
|
"speed": speed,
|
|
"stream": false,
|
|
"return_download_link": true,
|
|
})
|
|
|
|
req, err := http.NewRequestWithContext(ctx, http.MethodPost,
|
|
s.kokoroURL+"/v1/audio/speech", bytes.NewReader(reqBody))
|
|
if err != nil {
|
|
return "", fmt.Errorf("build request: %w", err)
|
|
}
|
|
req.Header.Set("Content-Type", "application/json")
|
|
|
|
resp, err := http.DefaultClient.Do(req)
|
|
if err != nil {
|
|
return "", fmt.Errorf("kokoro request: %w", err)
|
|
}
|
|
defer resp.Body.Close()
|
|
// Drain body so the connection can be reused.
|
|
_, _ = io.Copy(io.Discard, resp.Body)
|
|
|
|
if resp.StatusCode != http.StatusOK {
|
|
return "", fmt.Errorf("kokoro status %d", resp.StatusCode)
|
|
}
|
|
|
|
// X-Download-Path is e.g. "/download/speech_abc123.mp3"
|
|
dlPath := resp.Header.Get("X-Download-Path")
|
|
if dlPath == "" {
|
|
return "", fmt.Errorf("kokoro did not return X-Download-Path header")
|
|
}
|
|
|
|
// Extract just the filename from the path.
|
|
filename := dlPath
|
|
if idx := strings.LastIndex(dlPath, "/"); idx >= 0 {
|
|
filename = dlPath[idx+1:]
|
|
}
|
|
if filename == "" {
|
|
return "", fmt.Errorf("empty filename in X-Download-Path: %q", dlPath)
|
|
}
|
|
return filename, nil
|
|
}
|
|
|
|
// downloadFromKokoro downloads a generated audio file from Kokoro's temp storage
|
|
// using GET /v1/download/{filename} and returns the raw bytes.
|
|
func (s *Server) downloadFromKokoro(ctx context.Context, filename string) ([]byte, error) {
|
|
url := s.kokoroURL + "/v1/download/" + filename
|
|
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("build download request: %w", err)
|
|
}
|
|
resp, err := http.DefaultClient.Do(req)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("kokoro download request: %w", err)
|
|
}
|
|
defer resp.Body.Close()
|
|
if resp.StatusCode != http.StatusOK {
|
|
return nil, fmt.Errorf("kokoro download status %d", resp.StatusCode)
|
|
}
|
|
data, err := io.ReadAll(resp.Body)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("read kokoro download body: %w", err)
|
|
}
|
|
return data, nil
|
|
}
|
|
|
|
// 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)
|
|
w.Header().Set("Content-Type", "application/json")
|
|
_ = json.NewEncoder(w).Encode(map[string]interface{}{
|
|
"url": proxyURL,
|
|
"filename": filename,
|
|
})
|
|
}
|
|
|
|
// handleAudioProxy handles GET /api/audio-proxy/{slug}/{n}.
|
|
// It looks up the Kokoro download filename for this chapter (voice) and
|
|
// proxies GET /v1/download/{filename} from the Kokoro server back to the browser.
|
|
func (s *Server) handleAudioProxy(w http.ResponseWriter, r *http.Request) {
|
|
slug := r.PathValue("slug")
|
|
n, err := strconv.Atoi(r.PathValue("n"))
|
|
if err != nil || n < 1 {
|
|
http.NotFound(w, r)
|
|
return
|
|
}
|
|
voice := r.URL.Query().Get("voice")
|
|
if voice == "" {
|
|
voice = s.kokoroVoice
|
|
}
|
|
|
|
cacheKey := fmt.Sprintf("%s/%d/%s", slug, n, voice)
|
|
filename, ok := s.store.GetAudioCache(r.Context(), cacheKey)
|
|
if !ok {
|
|
http.Error(w, "audio not generated yet", http.StatusNotFound)
|
|
return
|
|
}
|
|
|
|
kokoroURL := s.kokoroURL + "/v1/download/" + filename
|
|
req, err := http.NewRequestWithContext(r.Context(), http.MethodGet, kokoroURL, nil)
|
|
if err != nil {
|
|
http.Error(w, "failed to build proxy request", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
resp, err := http.DefaultClient.Do(req)
|
|
if err != nil {
|
|
http.Error(w, "kokoro download failed", http.StatusBadGateway)
|
|
return
|
|
}
|
|
defer resp.Body.Close()
|
|
|
|
if resp.StatusCode != http.StatusOK {
|
|
http.Error(w, fmt.Sprintf("kokoro returned %d", resp.StatusCode), http.StatusBadGateway)
|
|
return
|
|
}
|
|
|
|
w.Header().Set("Content-Type", "audio/mpeg")
|
|
w.Header().Set("Cache-Control", "public, max-age=3600")
|
|
if cl := resp.Header.Get("Content-Length"); cl != "" {
|
|
w.Header().Set("Content-Length", cl)
|
|
}
|
|
_, _ = io.Copy(w, resp.Body)
|
|
}
|
|
|
|
// ─── Presigned URL handlers ───────────────────────────────────────────────────
|
|
|
|
// handlePresignChapter handles GET /api/presign/chapter/{slug}/{n}.
|
|
// Returns a short-lived presigned MinIO URL for the chapter markdown object.
|
|
// The SvelteKit server uses this to fetch chapter content server-side.
|
|
func (s *Server) handlePresignChapter(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
|
|
}
|
|
|
|
url, err := s.store.PresignChapter(r.Context(), slug, n, 15*time.Minute)
|
|
if err != nil {
|
|
s.log.Error("presign chapter failed", "slug", slug, "n", n, "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})
|
|
}
|
|
|
|
// handlePresignAudio handles GET /api/presign/audio/{slug}/{n}.
|
|
// Returns a presigned MinIO URL for the audio object (if it has been generated).
|
|
// Query params: voice (optional, defaults to server default).
|
|
func (s *Server) handlePresignAudio(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
|
|
}
|
|
|
|
key := s.store.AudioObjectKey(slug, n, voice)
|
|
|
|
// Return 404 when the object hasn't been uploaded yet — the client treats
|
|
// this as "audio not ready" and will either poll or trigger generation.
|
|
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 audio failed", "slug", slug, "n", n, "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})
|
|
}
|
|
|
|
// ─── 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)
|
|
}
|
|
|
|
// 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.
|
|
// 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})
|
|
}
|