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;
|
||||
}
|
||||
|
||||
// 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';
|
||||
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