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
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:
@@ -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.
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user