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

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:
Admin
2026-04-04 19:49:53 +05:00
parent 0e0a70a786
commit 25150c2284
2 changed files with 158 additions and 1 deletions

View File

@@ -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();

View 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 });
};