|
|
|
|
@@ -425,9 +425,12 @@ func (s *Server) handleChapterText(w http.ResponseWriter, r *http.Request) {
|
|
|
|
|
// 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/speed) and
|
|
|
|
|
// We cache that filename (in memory, keyed by slug/chapter/voice) and
|
|
|
|
|
// return a proxy URL that the browser sets as audio.src.
|
|
|
|
|
//
|
|
|
|
|
// TTS is always generated at speed 1.0; playback speed is controlled
|
|
|
|
|
// client-side via the <audio> element's playbackRate.
|
|
|
|
|
//
|
|
|
|
|
// On a cache hit the proxy URL is returned immediately without re-generating.
|
|
|
|
|
// Concurrent requests for the same key are deduplicated.
|
|
|
|
|
func (s *Server) handleAudioGenerate(w http.ResponseWriter, r *http.Request) {
|
|
|
|
|
@@ -438,13 +441,12 @@ func (s *Server) handleAudioGenerate(w http.ResponseWriter, r *http.Request) {
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Parse optional voice/speed from JSON body.
|
|
|
|
|
// Parse optional voice from JSON body. Speed is intentionally ignored —
|
|
|
|
|
// TTS is always generated at 1.0; playback speed is applied client-side.
|
|
|
|
|
voice := s.kokoroVoice
|
|
|
|
|
speed := 1.0
|
|
|
|
|
var body struct {
|
|
|
|
|
Voice string `json:"voice"`
|
|
|
|
|
Speed float64 `json:"speed"`
|
|
|
|
|
MaxChars int `json:"max_chars"`
|
|
|
|
|
Voice string `json:"voice"`
|
|
|
|
|
MaxChars int `json:"max_chars"`
|
|
|
|
|
}
|
|
|
|
|
if r.Body != nil {
|
|
|
|
|
_ = json.NewDecoder(r.Body).Decode(&body)
|
|
|
|
|
@@ -452,15 +454,12 @@ func (s *Server) handleAudioGenerate(w http.ResponseWriter, r *http.Request) {
|
|
|
|
|
if body.Voice != "" {
|
|
|
|
|
voice = body.Voice
|
|
|
|
|
}
|
|
|
|
|
if body.Speed > 0 {
|
|
|
|
|
speed = body.Speed
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
cacheKey := fmt.Sprintf("%s/%d/%s/%.2f", slug, n, voice, speed)
|
|
|
|
|
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, speed, filename)
|
|
|
|
|
s.writeAudioResponse(w, slug, n, voice, filename)
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
@@ -476,7 +475,7 @@ func (s *Server) handleAudioGenerate(w http.ResponseWriter, r *http.Request) {
|
|
|
|
|
}
|
|
|
|
|
// Check store again after waiting.
|
|
|
|
|
if filename, ok := s.store.GetAudioCache(r.Context(), cacheKey); ok {
|
|
|
|
|
s.writeAudioResponse(w, slug, n, voice, speed, filename)
|
|
|
|
|
s.writeAudioResponse(w, slug, n, voice, filename)
|
|
|
|
|
} else {
|
|
|
|
|
http.Error(w, `{"error":"audio generation failed"}`, http.StatusInternalServerError)
|
|
|
|
|
}
|
|
|
|
|
@@ -512,10 +511,10 @@ func (s *Server) handleAudioGenerate(w http.ResponseWriter, r *http.Request) {
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Call Kokoro POST /v1/audio/speech with return_download_link=true.
|
|
|
|
|
// 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, speed)
|
|
|
|
|
filename, err := s.generateSpeech(r.Context(), 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)
|
|
|
|
|
@@ -528,7 +527,7 @@ func (s *Server) handleAudioGenerate(w http.ResponseWriter, r *http.Request) {
|
|
|
|
|
|
|
|
|
|
// Download generated audio from Kokoro and persist to MinIO synchronously
|
|
|
|
|
// so that the presigned URL returned to the client is immediately valid.
|
|
|
|
|
minioKey := s.store.AudioObjectKey(slug, n, voice, speed)
|
|
|
|
|
minioKey := s.store.AudioObjectKey(slug, n, voice)
|
|
|
|
|
audioData, dlErr := s.downloadFromKokoro(r.Context(), filename)
|
|
|
|
|
if dlErr != nil {
|
|
|
|
|
s.log.Warn("audio MinIO upload skipped: kokoro download failed",
|
|
|
|
|
@@ -541,7 +540,7 @@ func (s *Server) handleAudioGenerate(w http.ResponseWriter, r *http.Request) {
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
s.log.Info("audio generated", "slug", slug, "chapter", n, "filename", filename)
|
|
|
|
|
s.writeAudioResponse(w, slug, n, voice, speed, filename)
|
|
|
|
|
s.writeAudioResponse(w, slug, n, voice, filename)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// generateSpeech calls POST /v1/audio/speech on Kokoro with return_download_link=true
|
|
|
|
|
@@ -618,8 +617,8 @@ func (s *Server) downloadFromKokoro(ctx context.Context, filename string) ([]byt
|
|
|
|
|
|
|
|
|
|
// writeAudioResponse writes the JSON response for a generated 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, speed float64, filename string) {
|
|
|
|
|
proxyURL := fmt.Sprintf("/api/audio-proxy/%s/%d?voice=%s&speed=%.1f", slug, n, voice, speed)
|
|
|
|
|
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,
|
|
|
|
|
@@ -628,7 +627,7 @@ func (s *Server) writeAudioResponse(w http.ResponseWriter, slug string, n int, v
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// handleAudioProxy handles GET /api/audio-proxy/{slug}/{n}.
|
|
|
|
|
// It looks up the Kokoro download filename for this chapter (voice/speed) and
|
|
|
|
|
// 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")
|
|
|
|
|
@@ -641,15 +640,8 @@ func (s *Server) handleAudioProxy(w http.ResponseWriter, r *http.Request) {
|
|
|
|
|
if voice == "" {
|
|
|
|
|
voice = s.kokoroVoice
|
|
|
|
|
}
|
|
|
|
|
speedStr := r.URL.Query().Get("speed")
|
|
|
|
|
speed := 1.0
|
|
|
|
|
if speedStr != "" {
|
|
|
|
|
if v, err := strconv.ParseFloat(speedStr, 64); err == nil && v > 0 {
|
|
|
|
|
speed = v
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
cacheKey := fmt.Sprintf("%s/%d/%s/%.2f", slug, n, voice, speed)
|
|
|
|
|
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)
|
|
|
|
|
@@ -709,7 +701,7 @@ func (s *Server) handlePresignChapter(w http.ResponseWriter, r *http.Request) {
|
|
|
|
|
|
|
|
|
|
// 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, speed (optional, defaults to server defaults).
|
|
|
|
|
// 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"))
|
|
|
|
|
@@ -722,14 +714,8 @@ func (s *Server) handlePresignAudio(w http.ResponseWriter, r *http.Request) {
|
|
|
|
|
if voice == "" {
|
|
|
|
|
voice = s.kokoroVoice
|
|
|
|
|
}
|
|
|
|
|
speed := 1.0
|
|
|
|
|
if sv := r.URL.Query().Get("speed"); sv != "" {
|
|
|
|
|
if v, err := strconv.ParseFloat(sv, 64); err == nil && v > 0 {
|
|
|
|
|
speed = v
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
key := s.store.AudioObjectKey(slug, n, voice, speed)
|
|
|
|
|
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.
|
|
|
|
|
|