feat: TTS streaming — Kokoro/PocketTTS audio starts immediately
All checks were successful
Release / Test backend (push) Successful in 37s
Release / Check ui (push) Successful in 44s
Release / Docker / caddy (push) Successful in 48s
Release / Docker / backend (push) Successful in 4m38s
Release / Docker / runner (push) Successful in 2m49s
Release / Docker / ui (push) Successful in 2m13s
Release / Gitea Release (push) Successful in 57s
All checks were successful
Release / Test backend (push) Successful in 37s
Release / Check ui (push) Successful in 44s
Release / Docker / caddy (push) Successful in 48s
Release / Docker / backend (push) Successful in 4m38s
Release / Docker / runner (push) Successful in 2m49s
Release / Docker / ui (push) Successful in 2m13s
Release / Gitea Release (push) Successful in 57s
Previously all voices waited for full TTS generation before first byte
reached the browser (POST → poll → presign flow). For long chapters this
meant 2–5 minutes of silence.
New flow for Kokoro and PocketTTS:
- tryPresign fast path unchanged (audio in MinIO → seekable presigned URL)
- On cache miss: set audioEl.src to /api/audio-stream/{slug}/{n} and mark
status=ready immediately — audio starts playing within seconds
- Backend streams bytes to browser while concurrently uploading to MinIO;
subsequent plays use the fast path
New SvelteKit route: GET /api/audio-stream/[slug]/[n]
- Proxies backend handleAudioStream (already implemented in Go)
- Same 3 chapters/day free paywall as POST route
- HEAD handler for paywall pre-check (no side effects, no counter increment)
so AudioPlayer can surface upgrade CTA before pointing <audio> at URL
CF AI voices keep the old POST+poll flow (batch-only API, no real streaming
benefit, preserves the generating progress bar UX).
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -567,7 +567,30 @@
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Slow path: trigger Kokoro generation (non-blocking POST), then poll.
|
// Slow path: audio not yet in MinIO.
|
||||||
|
//
|
||||||
|
// For Kokoro / PocketTTS when presign has NOT already enqueued the runner:
|
||||||
|
// use the streaming endpoint — audio starts playing within seconds while
|
||||||
|
// generation runs and MinIO is populated concurrently.
|
||||||
|
// Skip when enqueued=true to avoid double-generation with the async runner.
|
||||||
|
if (!voice.startsWith('cfai:') && !presignResult.enqueued) {
|
||||||
|
const qs = new URLSearchParams({ voice, format: 'mp3' });
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
// CF AI (batch-only) or already enqueued by presign: keep the traditional
|
||||||
|
// POST → poll → presign flow. For enqueued, we skip the POST and poll.
|
||||||
audioStore.status = 'generating';
|
audioStore.status = 'generating';
|
||||||
startProgress();
|
startProgress();
|
||||||
|
|
||||||
|
|||||||
134
ui/src/routes/api/audio-stream/[slug]/[n]/+server.ts
Normal file
134
ui/src/routes/api/audio-stream/[slug]/[n]/+server.ts
Normal file
@@ -0,0 +1,134 @@
|
|||||||
|
import { error } from '@sveltejs/kit';
|
||||||
|
import type { RequestHandler } from './$types';
|
||||||
|
import { log } from '$lib/server/logger';
|
||||||
|
import { backendFetch } from '$lib/server/scraper';
|
||||||
|
import * as cache from '$lib/server/cache';
|
||||||
|
|
||||||
|
const FREE_DAILY_AUDIO_LIMIT = 3;
|
||||||
|
|
||||||
|
function dailyAudioKey(identifier: string): string {
|
||||||
|
const today = new Date().toISOString().slice(0, 10);
|
||||||
|
return `audio:daily:${identifier}:${today}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Return the number of audio chapters a user/session has generated today,
|
||||||
|
* and increment the counter. Shared logic with POST /api/audio/[slug]/[n].
|
||||||
|
*
|
||||||
|
* Key: audio:daily:<userId|sessionId>:<YYYY-MM-DD>
|
||||||
|
*/
|
||||||
|
async function incrementDailyAudioCount(identifier: string): Promise<number> {
|
||||||
|
const key = dailyAudioKey(identifier);
|
||||||
|
const now = new Date();
|
||||||
|
const endOfDay = new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate() + 1));
|
||||||
|
const ttl = Math.ceil((endOfDay.getTime() - now.getTime()) / 1000);
|
||||||
|
try {
|
||||||
|
const raw = await cache.get<number>(key);
|
||||||
|
const current = (raw ?? 0) + 1;
|
||||||
|
await cache.set(key, current, ttl);
|
||||||
|
return current;
|
||||||
|
} catch {
|
||||||
|
// On cache failure, fail open (don't block audio for cache errors)
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /api/audio-stream/[slug]/[n]?voice=...
|
||||||
|
*
|
||||||
|
* Proxies the backend's streaming TTS endpoint to the browser.
|
||||||
|
*
|
||||||
|
* Fast path: if audio already in MinIO, backend sends a 302 → fetch follows it
|
||||||
|
* and we stream the MinIO audio through.
|
||||||
|
*
|
||||||
|
* Slow path (Kokoro/PocketTTS): backend streams audio bytes as they are
|
||||||
|
* generated. The browser receives the first bytes within seconds and can start
|
||||||
|
* playing before generation completes. MinIO upload happens concurrently
|
||||||
|
* server-side so subsequent requests use the cached fast path.
|
||||||
|
*
|
||||||
|
* Slow path (CF AI): backend buffers the full response (batch API limitation)
|
||||||
|
* before sending — effectively the same as the old POST+poll approach but
|
||||||
|
* without the separate progress-bar flow. Prefer the traditional POST route
|
||||||
|
* for CF AI voices to preserve the generating UI.
|
||||||
|
*/
|
||||||
|
export const GET: RequestHandler = async ({ params, url, locals }) => {
|
||||||
|
const { slug, n } = params;
|
||||||
|
const chapter = parseInt(n, 10);
|
||||||
|
if (!slug || !chapter || chapter < 1) {
|
||||||
|
error(400, 'Invalid slug or chapter number');
|
||||||
|
}
|
||||||
|
|
||||||
|
const voice = url.searchParams.get('voice') ?? '';
|
||||||
|
|
||||||
|
// ── Paywall: 3 audio chapters/day for free users ─────────────────────────
|
||||||
|
// Only count when the audio is not already cached — same rule as POST route.
|
||||||
|
if (!locals.isPro) {
|
||||||
|
const statusRes = await backendFetch(
|
||||||
|
`/api/audio/status/${slug}/${chapter}${voice ? `?voice=${encodeURIComponent(voice)}` : ''}`
|
||||||
|
).catch(() => null);
|
||||||
|
const statusData = statusRes?.ok
|
||||||
|
? ((await statusRes.json().catch(() => ({}))) as { status?: string })
|
||||||
|
: {};
|
||||||
|
|
||||||
|
if (statusData.status !== 'done') {
|
||||||
|
const identifier = locals.user?.id ?? locals.sessionId;
|
||||||
|
const count = await incrementDailyAudioCount(identifier);
|
||||||
|
if (count > FREE_DAILY_AUDIO_LIMIT) {
|
||||||
|
log.info('polar', 'free audio stream limit reached', { identifier, count });
|
||||||
|
return new Response(
|
||||||
|
JSON.stringify({ error: 'pro_required', limit: FREE_DAILY_AUDIO_LIMIT }),
|
||||||
|
{ status: 402, headers: { 'Content-Type': 'application/json' } }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const qs = new URLSearchParams({ format: 'mp3' });
|
||||||
|
if (voice) qs.set('voice', voice);
|
||||||
|
|
||||||
|
// fetch() follows the backend's 302 (MinIO fast path) automatically.
|
||||||
|
const backendRes = await backendFetch(`/api/audio-stream/${slug}/${chapter}?${qs}`);
|
||||||
|
|
||||||
|
if (!backendRes.ok) {
|
||||||
|
const text = await backendRes.text().catch(() => '');
|
||||||
|
log.error('audio-stream', 'backend stream failed', {
|
||||||
|
slug,
|
||||||
|
chapter,
|
||||||
|
status: backendRes.status,
|
||||||
|
body: text
|
||||||
|
});
|
||||||
|
error(backendRes.status as Parameters<typeof error>[0], text || 'Audio stream failed');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Stream the response body directly — no buffering.
|
||||||
|
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'
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* HEAD /api/audio-stream/[slug]/[n]?voice=...
|
||||||
|
*
|
||||||
|
* Paywall pre-check without incrementing the daily counter or triggering
|
||||||
|
* any generation. The AudioPlayer uses this to surface the upgrade CTA
|
||||||
|
* before pointing the <audio> element at the streaming URL.
|
||||||
|
*
|
||||||
|
* Returns 402 if the user has already hit their daily limit, 200 otherwise.
|
||||||
|
*/
|
||||||
|
export const HEAD: RequestHandler = async ({ locals }) => {
|
||||||
|
if (locals.isPro) {
|
||||||
|
return new Response(null, { status: 200 });
|
||||||
|
}
|
||||||
|
const identifier = locals.user?.id ?? locals.sessionId;
|
||||||
|
const count = (await cache.get<number>(dailyAudioKey(identifier))) ?? 0;
|
||||||
|
// count >= limit means the next GET would exceed the limit after increment
|
||||||
|
if (count >= FREE_DAILY_AUDIO_LIMIT) {
|
||||||
|
return new Response(null, { status: 402 });
|
||||||
|
}
|
||||||
|
return new Response(null, { status: 200 });
|
||||||
|
};
|
||||||
Reference in New Issue
Block a user