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

@@ -160,6 +160,19 @@ class AudioStore {
return this.slug === slug && this.chapter === chapter;
}
// ── Announce-chapter navigation state ────────────────────────────────────
/**
* When true, the <audio> element is playing a short announcement clip
* (not chapter audio). The next `onended` should navigate to
* announcePendingSlug / announcePendingChapter instead of the normal
* auto-next flow.
*/
announceNavigatePending = $state(false);
/** Target book slug for the pending announce-then-navigate transition. */
announcePendingSlug = $state('');
/** Target chapter number for the pending announce-then-navigate transition. */
announcePendingChapter = $state(0);
/** Reset all next-chapter pre-fetch state. */
resetNextPrefetch() {
this.nextStatus = 'none';

View File

@@ -260,6 +260,11 @@
navigator.mediaSession.playbackState = audioStore.isPlaying ? 'playing' : 'paused';
});
// ── Announce-chapter safety timeout ──────────────────────────────────────
// Module-level so the onended handler can clear it if the clip completes
// before the timeout fires.
let announceTimeout = 0;
// ── Save audio time on pause/end (debounced 2s) ─────────────────────────
let audioTimeSaveTimer = 0;
function saveAudioTime() {
@@ -366,6 +371,22 @@
}}
onended={() => {
audioStore.isPlaying = false;
// ── If we just finished playing an announcement clip, navigate now ──
if (audioStore.announceNavigatePending) {
audioStore.announceNavigatePending = false;
clearTimeout(announceTimeout);
announceTimeout = 0;
const slug = audioStore.announcePendingSlug;
const chapter = audioStore.announcePendingChapter;
audioStore.announcePendingSlug = '';
audioStore.announcePendingChapter = 0;
goto(`/books/${slug}/chapters/${chapter}`).catch(() => {
audioStore.autoStartChapter = null;
});
return;
}
// Cancel any pending debounced save and reset the position to 0 for
// the chapter that just finished. Without this, the 2s debounce fires
// after navigation and saves currentTime≈duration, causing resume to
@@ -390,45 +411,53 @@
// Capture values synchronously before any async work — the AudioPlayer
// component will unmount during navigation, but we've already read what
// we need.
const targetSlug = audioStore.slug;
const targetSlug = audioStore.slug;
const targetChapter = audioStore.nextChapter;
// Store the target chapter number so only the newly-mounted AudioPlayer
// for that chapter reacts — not the outgoing chapter's component.
audioStore.autoStartChapter = targetChapter;
// Announce the upcoming chapter via Web Speech API if enabled.
const doNavigate = () => {
goto(`/books/${targetSlug}/chapters/${targetChapter}`).catch(() => {
audioStore.autoStartChapter = null;
});
};
if (audioStore.announceChapter && typeof window !== 'undefined' && 'speechSynthesis' in window) {
const nextInfo = audioStore.chapters.find((c) => c.number === targetChapter);
// Announce via a real audio clip so the audio session stays alive on
// iOS Safari / Chrome Android (speechSynthesis is silently muted after
// onended because the audio session has been released).
if (audioStore.announceChapter) {
const nextInfo = audioStore.chapters.find((c) => c.number === targetChapter);
const titlePart = nextInfo?.title ? ` ${nextInfo.title}` : '';
const text = `Chapter ${targetChapter}${titlePart}`;
window.speechSynthesis.cancel();
const utterance = new SpeechSynthesisUtterance(text);
const text = `Chapter ${targetChapter}${titlePart}`;
// Guard: ensure doNavigate can only fire once even if both
// onend and the timeout fire, or onerror fires after onend.
let navigated = false;
const safeNavigate = () => {
if (navigated) return;
navigated = true;
clearTimeout(announceTimeout);
doNavigate();
};
// Always request MP3 — universally supported and the backend
// auto-selects the right TTS engine from the voice ID.
const qs = new URLSearchParams({ text, voice: audioStore.voice, format: 'mp3' });
const announceUrl = `/api/announce?${qs}`;
// Hard fallback: if speechSynthesis silently drops the utterance
// (common on Chrome Android due to gesture policy, or when the
// browser is busy fetching the next chapter's audio), navigate
// anyway after a generous 8-second window.
const announceTimeout = setTimeout(safeNavigate, 8000);
// Store pending navigation target so the next onended (from the
// announcement clip) knows where to go.
audioStore.announcePendingSlug = targetSlug;
audioStore.announcePendingChapter = targetChapter;
audioStore.announceNavigatePending = true;
utterance.onend = safeNavigate;
utterance.onerror = safeNavigate;
window.speechSynthesis.speak(utterance);
// Safety timeout: if the clip never loads/ends (network issue,
// browser policy, unsupported codec), navigate anyway after 10s.
clearTimeout(announceTimeout);
announceTimeout = setTimeout(() => {
if (audioStore.announceNavigatePending) {
audioStore.announceNavigatePending = false;
audioStore.announcePendingSlug = '';
audioStore.announcePendingChapter = 0;
doNavigate();
}
}, 10_000) as unknown as number;
// Point the persistent <audio> element at the announcement clip.
// The $effect in the layout that watches audioStore.audioUrl will
// pick this up, set audioEl.src, and call play().
audioStore.audioUrl = announceUrl;
} else {
doNavigate();
}

View File

@@ -0,0 +1,39 @@
import { error } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
import { backendFetch } from '$lib/server/scraper';
/**
* GET /api/announce?text=...&voice=...&format=...
*
* Thin proxy to backend GET /api/tts-announce.
* No paywall — this is a short announcement clip (a few words), not chapter audio.
* No MinIO caching — the backend streams the clip directly.
*/
export const GET: RequestHandler = async ({ url }) => {
const text = url.searchParams.get('text') ?? '';
if (!text) error(400, 'text is required');
const qs = new URLSearchParams();
qs.set('text', text);
const voice = url.searchParams.get('voice');
if (voice) qs.set('voice', voice);
const format = url.searchParams.get('format') ?? 'mp3';
qs.set('format', format);
const backendRes = await backendFetch(`/api/tts-announce?${qs}`);
if (!backendRes.ok) {
error(backendRes.status as Parameters<typeof error>[0], 'TTS announce failed');
}
return new Response(backendRes.body, {
status: 200,
headers: {
'Content-Type': backendRes.headers.get('Content-Type') ?? 'audio/mpeg',
'Cache-Control': 'no-store',
'X-Accel-Buffering': 'no'
}
});
};