fix(audio): remove speed from TTS/cache/MinIO keys; fix presigned URL host rewrite

- Speed is no longer part of Kokoro generation, in-memory cache keys, or
  MinIO object keys — audio is always generated at 1.0 and playback speed
  is applied client-side via audioEl.playbackRate
- presignAudio() now calls rewriteHost() so chapter audio URLs use the
  public MinIO endpoint (same as presignVoiceSample already did)
- docker-compose.yml: rename MINIO_PUBLIC_ENDPOINT → PUBLIC_MINIO_PUBLIC_URL
  for the ui service so SvelteKit's $env/dynamic/public picks it up
This commit is contained in:
Admin
2026-03-04 19:30:46 +05:00
parent c8e0cf2813
commit acbfafb8cd
9 changed files with 45 additions and 72 deletions

View File

@@ -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.

View File

@@ -287,8 +287,8 @@ func (h *HybridStore) DeleteProgress(ctx context.Context, sessionID, slug string
// ─── AudioObjectKey ───────────────────────────────────────────────────────────
func (h *HybridStore) AudioObjectKey(slug string, n int, voice string, speed float64) string {
return AudioObjectKey(slug, n, voice, speed)
func (h *HybridStore) AudioObjectKey(slug string, n int, voice string) string {
return AudioObjectKey(slug, n, voice)
}
func (h *HybridStore) AudioExists(ctx context.Context, key string) bool {

View File

@@ -154,10 +154,10 @@ func (m *MinioClient) CountChapters(ctx context.Context, slug string) int {
// ─── Audio objects ────────────────────────────────────────────────────────────
// AudioObjectKey returns the MinIO key for a cached audio file.
// Key: {slug}/ch{n}-{voice}-{speed:.1f}.mp3
func AudioObjectKey(slug string, n int, voice string, speed float64) string {
// Key: {slug}/ch{n}-{voice}.mp3
func AudioObjectKey(slug string, n int, voice string) string {
safe := sanitiseVoice(voice)
return fmt.Sprintf("%s/ch%d-%s-%.1f.mp3", slug, n, safe, speed)
return fmt.Sprintf("%s/ch%d-%s.mp3", slug, n, safe)
}
// PutAudio stores an audio file in the audio bucket.

View File

@@ -129,7 +129,7 @@ type Store interface {
// ── Audio object paths (MinIO) ─────────────────────────────────────────
// AudioObjectKey returns the MinIO object key for a cached audio file.
AudioObjectKey(slug string, n int, voice string, speed float64) string
AudioObjectKey(slug string, n int, voice string) string
// AudioExists returns true when the audio object is present in the bucket.
AudioExists(ctx context.Context, key string) bool