fix: replace speechSynthesis announce with real audio clip via /api/tts-announce
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:
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user