diff --git a/ui/src/lib/audio.svelte.ts b/ui/src/lib/audio.svelte.ts index ab71cb3..fb77064 100644 --- a/ui/src/lib/audio.svelte.ts +++ b/ui/src/lib/audio.svelte.ts @@ -36,6 +36,14 @@ import type { Voice } from '$lib/types'; export type AudioStatus = 'idle' | 'loading' | 'generating' | 'ready' | 'error'; export type NextStatus = 'none' | 'prefetching' | 'prefetched' | 'failed'; +/** + * 'stream' – Use /api/audio-stream: audio starts playing within seconds, + * stream is saved to MinIO concurrently. No runner task needed. + * 'generate' – Legacy mode: queue a runner task, poll until done, then play + * from the presigned MinIO URL. Needed for CF AI voices which + * do not support native streaming. + */ +export type AudioMode = 'stream' | 'generate'; class AudioStore { // ── What is loaded ────────────────────────────────────────────────────── @@ -46,6 +54,13 @@ class AudioStore { voice = $state('af_bella'); speed = $state(1.0); + /** + * Playback mode: + * 'stream' – pipe from /api/audio-stream (low latency, saves concurrently) + * 'generate' – queue runner task, poll, then play presigned URL (CF AI / legacy) + */ + audioMode = $state('stream'); + /** Cover image URL for the currently loaded book. */ cover = $state(''); diff --git a/ui/src/lib/components/AudioPlayer.svelte b/ui/src/lib/components/AudioPlayer.svelte index a4ddbe0..7cceeba 100644 --- a/ui/src/lib/components/AudioPlayer.svelte +++ b/ui/src/lib/components/AudioPlayer.svelte @@ -613,41 +613,95 @@ return; } - // Slow path: audio not yet in MinIO. - // - // For Kokoro / PocketTTS: always use the streaming endpoint so audio - // starts playing within seconds. The stream handler checks MinIO first - // (fast redirect if already cached) and otherwise generates + uploads - // concurrently. Even if the async runner is already working on this - // chapter, the stream will redirect to MinIO the moment the runner - // finishes — no harmful double-generation occurs because the backend - // deduplications via AudioExists on the next request. - if (!voice.startsWith('cfai:')) { - // PocketTTS outputs raw WAV — skip the ffmpeg transcode entirely. - // WAV (PCM) is natively supported on all platforms including iOS Safari. - // Kokoro and CF AI output MP3 natively, so keep mp3 for those. - const isPocketTTS = voices.some((v) => v.id === voice && v.engine === 'pocket-tts'); - const format = isPocketTTS ? 'wav' : 'mp3'; - const qs = new URLSearchParams({ voice, format }); - const streamUrl = `/api/audio-stream/${slug}/${chapter}?${qs}`; - // HEAD probe: check paywall without triggering generation. - const headRes = await fetch(streamUrl, { method: 'HEAD' }).catch(() => null); - if (headRes?.status === 402) { + // Slow path: audio not yet in MinIO. + // + // For Kokoro / PocketTTS in 'stream' mode: use the streaming endpoint so + // audio starts playing within seconds. The stream handler checks MinIO + // first (fast redirect if already cached) and otherwise generates + + // uploads concurrently. + // + // In 'generate' mode (user preference): queue a runner task and poll, + // same as CF AI — audio plays only after the full file is ready in MinIO. + if (!voice.startsWith('cfai:') && audioStore.audioMode === 'stream') { + // PocketTTS outputs raw WAV — skip the ffmpeg transcode entirely. + // WAV (PCM) is natively supported on all platforms including iOS Safari. + // Kokoro and CF AI output MP3 natively, so keep mp3 for those. + const isPocketTTS = voices.some((v) => v.id === voice && v.engine === 'pocket-tts'); + const format = isPocketTTS ? 'wav' : 'mp3'; + const qs = new URLSearchParams({ voice, format }); + const streamUrl = `/api/audio-stream/${slug}/${chapter}?${qs}`; + // HEAD probe: check paywall without triggering generation. + const headRes = await fetch(streamUrl, { method: 'HEAD' }).catch(() => null); + if (headRes?.status === 402) { + audioStore.status = 'idle'; + onProRequired?.(); + return; + } + audioStore.audioUrl = streamUrl; + audioStore.status = 'ready'; + maybeStartPrefetch(); + return; + } + + // Non-CF AI voices in 'generate' mode: queue runner task, show progress, + // wait for full audio in MinIO before playing (same as CF AI but no preview). + if (!voice.startsWith('cfai:')) { + audioStore.status = 'generating'; + audioStore.isPreview = false; + startProgress(); + + if (!presignResult.enqueued) { + const res = await fetch(`/api/audio/${slug}/${chapter}`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ voice }) + }); + + if (res.status === 402) { audioStore.status = 'idle'; + stopProgress(); onProRequired?.(); return; } - audioStore.audioUrl = streamUrl; - audioStore.status = 'ready'; - maybeStartPrefetch(); - return; + + if (!res.ok) throw new Error(`Generation failed: HTTP ${res.status}`); + + if (res.status === 200) { + await res.body?.cancel(); + await finishProgress(); + const doneUrl = await tryPresign(slug, chapter, voice); + if (!doneUrl.ready) throw new Error('Audio generated but presign returned 404'); + audioStore.audioUrl = doneUrl.url; + audioStore.status = 'ready'; + restoreSavedAudioTime(); + maybeStartPrefetch(); + return; + } + // 202 — runner task enqueued, fall through to poll. } - // CF AI voices: use preview/swap strategy. - // 1. Fetch a short ~1-2 min preview clip from the first text chunk - // so playback starts immediately — no more waiting behind a spinner. - // 2. Meanwhile keep polling the full audio job; when it finishes, - // swap the