fix: replace speechSynthesis announce with real audio clip via /api/tts-announce
All checks were successful
Release / Test backend (push) Successful in 41s
Release / Check ui (push) Successful in 1m48s
Release / Docker (push) Successful in 13m20s
Release / Gitea Release (push) Successful in 40s

speechSynthesis is silently muted on iOS Safari and Chrome Android after
the audio session ends (onended), so chapter announcements never played.

Fix:
- Add GET /api/tts-announce backend endpoint: streams a short TTS clip
  for arbitrary text without MinIO caching (backend/internal/backend/)
- Add GET /api/announce SvelteKit proxy route (no paywall)
- Add announceNavigatePending/announcePendingSlug/announcePendingChapter
  to AudioStore
- Rewrite onended announce branch: sets audioStore.audioUrl to the
  announcement clip URL so the persistent <audio> element plays it;
  the next onended detects announceNavigatePending and navigates
- 10s safety timeout in case the clip fails to load/end
This commit is contained in:
root
2026-04-08 11:57:04 +05:00
parent 559b6234e7
commit 85492fae73
5 changed files with 221 additions and 25 deletions

View File

@@ -9,6 +9,7 @@ package backend
// handleGetRanking, handleGetCover
// handleBookPreview, handleChapterText, handleChapterTextPreview, handleChapterMarkdown, handleReindex
// handleAudioGenerate, handleAudioStatus, handleAudioProxy, handleAudioStream
// handleTTSAnnounce
// handleVoices
// handlePresignChapter, handlePresignAudio, handlePresignVoiceSample
// handlePresignAvatarUpload, handlePresignAvatar
@@ -904,7 +905,119 @@ func (s *Server) handleAudioStream(w http.ResponseWriter, r *http.Request) {
// on its next poll as soon as the MinIO object is present.
}
// handleAudioPreview handles GET /api/audio-preview/{slug}/{n}.
// handleTTSAnnounce handles GET /api/tts-announce.
//
// Streams a short TTS clip for arbitrary text — used by the UI to announce
// the upcoming chapter number/title through the real <audio> element instead
// of the Web Speech API (which is silently muted on mobile after the audio
// session ends).
//
// Query params:
// - text — the text to synthesize (required, max 300 chars)
// - voice — voice ID (defaults to server default)
// - format — "mp3" or "wav" (default "mp3")
//
// No MinIO caching — announcement clips are tiny and ephemeral.
func (s *Server) handleTTSAnnounce(w http.ResponseWriter, r *http.Request) {
text := r.URL.Query().Get("text")
if text == "" {
jsonError(w, http.StatusBadRequest, "text is required")
return
}
if len(text) > 300 {
text = text[:300]
}
voice := r.URL.Query().Get("voice")
if voice == "" {
voice = s.cfg.DefaultVoice
}
format := r.URL.Query().Get("format")
if format != "wav" {
format = "mp3"
}
contentType := "audio/mpeg"
if format == "wav" {
contentType = "audio/wav"
}
var (
audioStream io.ReadCloser
err error
)
if format == "wav" {
if cfai.IsCFAIVoice(voice) {
if s.deps.CFAI == nil {
jsonError(w, http.StatusServiceUnavailable, "cloudflare AI TTS not configured")
return
}
audioStream, err = s.deps.CFAI.StreamAudioWAV(r.Context(), text, voice)
} else if pockettts.IsPocketTTSVoice(voice) {
if s.deps.PocketTTS == nil {
jsonError(w, http.StatusServiceUnavailable, "pocket-tts not configured")
return
}
audioStream, err = s.deps.PocketTTS.StreamAudioWAV(r.Context(), text, voice)
} else {
if s.deps.Kokoro == nil {
jsonError(w, http.StatusServiceUnavailable, "kokoro not configured")
return
}
audioStream, err = s.deps.Kokoro.StreamAudioWAV(r.Context(), text, voice)
}
} else {
if cfai.IsCFAIVoice(voice) {
if s.deps.CFAI == nil {
jsonError(w, http.StatusServiceUnavailable, "cloudflare AI TTS not configured")
return
}
audioStream, err = s.deps.CFAI.StreamAudioMP3(r.Context(), text, voice)
} else 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("handleTTSAnnounce: TTS stream failed", "voice", voice, "err", err)
jsonError(w, http.StatusInternalServerError, "tts stream failed")
return
}
defer audioStream.Close()
w.Header().Set("Content-Type", contentType)
w.Header().Set("Cache-Control", "no-store")
w.Header().Set("X-Accel-Buffering", "no")
w.WriteHeader(http.StatusOK)
flusher, canFlush := w.(http.Flusher)
buf := make([]byte, 32*1024)
for {
nr, readErr := audioStream.Read(buf)
if nr > 0 {
if _, writeErr := w.Write(buf[:nr]); writeErr != nil {
return
}
if canFlush {
flusher.Flush()
}
}
if readErr != nil {
break
}
}
}
//
// CF AI voices are batch-only and can take 1-2+ minutes to generate a full
// chapter. This endpoint generates only the FIRST chunk of text (~1 800 chars,

View File

@@ -180,6 +180,8 @@ func (s *Server) ListenAndServe(ctx context.Context) error {
// 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)
// TTS for arbitrary short text (chapter announcements) — no MinIO caching.
mux.HandleFunc("GET /api/tts-announce", s.handleTTSAnnounce)
// CF AI preview: generates only the first ~1 800-char chunk so the client
// can start playing immediately while the full audio is generated by the runner.
mux.HandleFunc("GET /api/audio-preview/{slug}/{n}", s.handleAudioPreview)