refactor(audiobooks): Refactor TTS generation and audiobook chapter creation
- Removed unused imports and types from TTS route. - Consolidated TTS buffer generation logic into a new module. - Implemented caching for TTS audio buffers using LRU cache. - Updated TTS request handling to support instructions for specific models. - Refactored audiobook chapter creation to use new TTS generation logic. - Simplified error handling and response management in TTS API. - Enhanced client-side logic to manage TTS requests and retries. - Updated types to reflect changes in TTS request payload structure.
This commit is contained in:
parent
df0e4341ff
commit
e41a5ba481
8 changed files with 559 additions and 424 deletions
|
|
@ -8,6 +8,9 @@ import { and, eq, inArray } from 'drizzle-orm';
|
|||
import { db } from '@/db';
|
||||
import { audiobooks, audiobookChapters } from '@/db/schema';
|
||||
import { requireAuthContext } from '@/lib/server/auth/auth';
|
||||
import { rateLimiter, RATE_LIMITS, isTtsRateLimitEnabled } from '@/lib/server/rate-limit/rate-limiter';
|
||||
import { getClientIp } from '@/lib/server/rate-limit/request-ip';
|
||||
import { getOrCreateDeviceId, setDeviceIdCookie } from '@/lib/server/rate-limit/device-id';
|
||||
import {
|
||||
deleteAudiobookObject,
|
||||
getAudiobookObjectBuffer,
|
||||
|
|
@ -25,14 +28,15 @@ import { isS3Configured } from '@/lib/server/storage/s3';
|
|||
import { getOpenReaderTestNamespace, getUnclaimedUserIdForNamespace } from '@/lib/server/testing/test-namespace';
|
||||
import { buildAllowedAudiobookUserIds, pickAudiobookOwner } from '@/lib/server/audiobooks/user-scope';
|
||||
import { getFFmpegPath } from '@/lib/server/audiobooks/ffmpeg-bin';
|
||||
import { generateTTSBuffer } from '@/lib/server/tts/generate';
|
||||
import type { AudiobookGenerationSettings } from '@/types/client';
|
||||
import type { TTSAudioBytes, TTSAudiobookFormat } from '@/types/tts';
|
||||
import type { TTSAudiobookFormat } from '@/types/tts';
|
||||
|
||||
export const dynamic = 'force-dynamic';
|
||||
|
||||
interface ConversionRequest {
|
||||
chapterTitle: string;
|
||||
buffer: TTSAudioBytes;
|
||||
text: string;
|
||||
bookId?: string;
|
||||
format?: TTSAudiobookFormat;
|
||||
chapterIndex?: number;
|
||||
|
|
@ -47,6 +51,35 @@ type ChapterObject = {
|
|||
};
|
||||
|
||||
const SAFE_ID_REGEX = /^[a-zA-Z0-9._-]{1,128}$/;
|
||||
const PROBLEM_TYPES = {
|
||||
dailyQuotaExceeded: 'https://openreader.app/problems/daily-quota-exceeded',
|
||||
} as const;
|
||||
|
||||
type ProblemDetails = {
|
||||
type: string;
|
||||
title: string;
|
||||
status: number;
|
||||
detail?: string;
|
||||
instance?: string;
|
||||
code?: string;
|
||||
[key: string]: unknown;
|
||||
};
|
||||
|
||||
function attachDeviceIdCookie(response: NextResponse, deviceId: string | null, didCreate: boolean) {
|
||||
if (didCreate && deviceId) {
|
||||
setDeviceIdCookie(response, deviceId);
|
||||
}
|
||||
}
|
||||
|
||||
function formatLimitForHint(limit: number): string {
|
||||
if (!Number.isFinite(limit) || limit <= 0) return String(limit);
|
||||
if (limit >= 1_000_000) {
|
||||
const m = limit / 1_000_000;
|
||||
return `${m % 1 === 0 ? m.toFixed(0) : m.toFixed(1)}M`;
|
||||
}
|
||||
if (limit >= 1_000) return `${Math.round(limit / 1_000)}K`;
|
||||
return String(limit);
|
||||
}
|
||||
|
||||
function isSafeId(value: string): boolean {
|
||||
return SAFE_ID_REGEX.test(value);
|
||||
|
|
@ -208,16 +241,21 @@ function findChapterFileNameByIndex(fileNames: string[], index: number): { fileN
|
|||
|
||||
export async function POST(request: NextRequest) {
|
||||
let workDir: string | null = null;
|
||||
let didCreateDeviceIdCookie = false;
|
||||
let deviceIdToSet: string | null = null;
|
||||
try {
|
||||
if (!isS3Configured()) return s3NotConfiguredResponse();
|
||||
|
||||
const data: ConversionRequest = await request.json();
|
||||
const requestedFormat = data.format || 'm4b';
|
||||
if (!data.text || typeof data.text !== 'string') {
|
||||
return NextResponse.json({ error: 'Missing text for TTS generation' }, { status: 400 });
|
||||
}
|
||||
|
||||
const ctxOrRes = await requireAuthContext(request);
|
||||
if (ctxOrRes instanceof Response) return ctxOrRes;
|
||||
|
||||
const { userId, authEnabled } = ctxOrRes;
|
||||
const { userId, authEnabled, user } = ctxOrRes;
|
||||
const testNamespace = getOpenReaderTestNamespace(request.headers);
|
||||
const unclaimedUserId = getUnclaimedUserIdForNamespace(testNamespace);
|
||||
const { preferredUserId, allowedUserIds } = buildAllowedAudiobookUserIds(authEnabled, userId, unclaimedUserId);
|
||||
|
|
@ -270,7 +308,8 @@ export async function POST(request: NextRequest) {
|
|||
existingSettings.voice !== incomingSettings.voice ||
|
||||
existingSettings.nativeSpeed !== incomingSettings.nativeSpeed ||
|
||||
existingSettings.postSpeed !== incomingSettings.postSpeed ||
|
||||
existingSettings.format !== incomingSettings.format;
|
||||
existingSettings.format !== incomingSettings.format ||
|
||||
(existingSettings.ttsInstructions || '') !== (incomingSettings.ttsInstructions || '');
|
||||
if (mismatch) {
|
||||
return NextResponse.json({ error: 'Audiobook settings mismatch', settings: existingSettings }, { status: 409 });
|
||||
}
|
||||
|
|
@ -309,12 +348,102 @@ export async function POST(request: NextRequest) {
|
|||
chapterIndex = next;
|
||||
}
|
||||
|
||||
const provider = request.headers.get('x-tts-provider')
|
||||
|| incomingSettings?.ttsProvider
|
||||
|| existingSettings?.ttsProvider
|
||||
|| 'openai';
|
||||
const openApiKey = request.headers.get('x-openai-key') || process.env.API_KEY || 'none';
|
||||
const openApiBaseUrl = request.headers.get('x-openai-base-url') || process.env.API_BASE;
|
||||
const model = incomingSettings?.ttsModel ?? existingSettings?.ttsModel;
|
||||
const voice = incomingSettings?.voice
|
||||
|| existingSettings?.voice
|
||||
|| (provider === 'openai'
|
||||
? 'alloy'
|
||||
: provider === 'deepinfra'
|
||||
? 'af_bella'
|
||||
: 'af_sarah');
|
||||
const rawNativeSpeed = incomingSettings?.nativeSpeed ?? existingSettings?.nativeSpeed ?? 1;
|
||||
const nativeSpeed = Number.isFinite(Number(rawNativeSpeed)) ? Number(rawNativeSpeed) : 1;
|
||||
const instructions = incomingSettings?.ttsInstructions ?? existingSettings?.ttsInstructions;
|
||||
|
||||
if (authEnabled && userId && isTtsRateLimitEnabled()) {
|
||||
const isAnonymous = Boolean(user?.isAnonymous);
|
||||
const charCount = data.text.length;
|
||||
const ip = getClientIp(request);
|
||||
const device = isAnonymous ? getOrCreateDeviceId(request) : null;
|
||||
if (device?.didCreate) {
|
||||
didCreateDeviceIdCookie = true;
|
||||
deviceIdToSet = device.deviceId;
|
||||
}
|
||||
|
||||
const rateLimitResult = await rateLimiter.checkAndIncrementLimit(
|
||||
{ id: userId, isAnonymous },
|
||||
charCount,
|
||||
{
|
||||
deviceId: device?.deviceId ?? null,
|
||||
ip,
|
||||
},
|
||||
);
|
||||
|
||||
if (!rateLimitResult.allowed) {
|
||||
const resetTimeMs = rateLimitResult.resetTimeMs;
|
||||
const retryAfterSeconds = Math.max(
|
||||
0,
|
||||
Math.ceil((resetTimeMs - Date.now()) / 1000),
|
||||
);
|
||||
|
||||
const problem: ProblemDetails = {
|
||||
type: PROBLEM_TYPES.dailyQuotaExceeded,
|
||||
title: 'Daily quota exceeded',
|
||||
status: 429,
|
||||
detail: 'Daily character limit exceeded',
|
||||
code: 'USER_DAILY_QUOTA_EXCEEDED',
|
||||
currentCount: rateLimitResult.currentCount,
|
||||
limit: rateLimitResult.limit,
|
||||
remainingChars: rateLimitResult.remainingChars,
|
||||
resetTimeMs,
|
||||
userType: isAnonymous ? 'anonymous' : 'authenticated',
|
||||
upgradeHint: isAnonymous
|
||||
? `Sign up to increase your limit from ${formatLimitForHint(RATE_LIMITS.ANONYMOUS)} to ${formatLimitForHint(RATE_LIMITS.AUTHENTICATED)} characters per day`
|
||||
: undefined,
|
||||
instance: request.nextUrl.pathname,
|
||||
};
|
||||
|
||||
const response = new NextResponse(JSON.stringify(problem), {
|
||||
status: 429,
|
||||
headers: {
|
||||
'Content-Type': 'application/problem+json',
|
||||
'Retry-After': String(retryAfterSeconds),
|
||||
},
|
||||
});
|
||||
|
||||
attachDeviceIdCookie(response, deviceIdToSet, didCreateDeviceIdCookie);
|
||||
return response;
|
||||
}
|
||||
}
|
||||
|
||||
const ttsBuffer = await generateTTSBuffer(
|
||||
{
|
||||
text: data.text,
|
||||
voice,
|
||||
speed: nativeSpeed,
|
||||
format: 'mp3',
|
||||
model,
|
||||
instructions,
|
||||
provider,
|
||||
apiKey: openApiKey,
|
||||
baseUrl: openApiBaseUrl,
|
||||
testNamespace,
|
||||
},
|
||||
request.signal,
|
||||
);
|
||||
|
||||
workDir = await mkdtemp(join(tmpdir(), 'openreader-audiobook-'));
|
||||
const inputPath = join(workDir, `${chapterIndex}-input.mp3`);
|
||||
const chapterOutputTempPath = join(workDir, `${chapterIndex}-chapter.tmp.${format}`);
|
||||
const titleTag = encodeChapterTitleTag(chapterIndex, data.chapterTitle);
|
||||
|
||||
await writeFile(inputPath, Buffer.from(new Uint8Array(data.buffer)));
|
||||
await writeFile(inputPath, ttsBuffer);
|
||||
|
||||
const canCopyMp3WithoutReencode = format === 'mp3' && postSpeed === 1;
|
||||
if (canCopyMp3WithoutReencode) {
|
||||
|
|
@ -398,7 +527,7 @@ export async function POST(request: NextRequest) {
|
|||
set: { title: data.chapterTitle, duration, format, filePath: finalChapterName },
|
||||
});
|
||||
|
||||
return NextResponse.json({
|
||||
const response = NextResponse.json({
|
||||
index: chapterIndex,
|
||||
title: data.chapterTitle,
|
||||
duration,
|
||||
|
|
@ -406,12 +535,18 @@ export async function POST(request: NextRequest) {
|
|||
bookId,
|
||||
format,
|
||||
});
|
||||
attachDeviceIdCookie(response, deviceIdToSet, didCreateDeviceIdCookie);
|
||||
return response;
|
||||
} catch (error) {
|
||||
if ((error as Error)?.message === 'ABORTED' || request.signal.aborted) {
|
||||
return NextResponse.json({ error: 'cancelled' }, { status: 499 });
|
||||
if ((error as Error)?.message === 'ABORTED' || (error as Error)?.name === 'AbortError' || request.signal.aborted) {
|
||||
const response = NextResponse.json({ error: 'cancelled' }, { status: 499 });
|
||||
attachDeviceIdCookie(response, deviceIdToSet, didCreateDeviceIdCookie);
|
||||
return response;
|
||||
}
|
||||
console.error('Error processing audio chapter:', error);
|
||||
return NextResponse.json({ error: 'Failed to process audio chapter' }, { status: 500 });
|
||||
const response = NextResponse.json({ error: 'Failed to process audio chapter' }, { status: 500 });
|
||||
attachDeviceIdCookie(response, deviceIdToSet, didCreateDeviceIdCookie);
|
||||
return response;
|
||||
} finally {
|
||||
if (workDir) await rm(workDir, { recursive: true, force: true }).catch(() => {});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,17 +1,19 @@
|
|||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import OpenAI from 'openai';
|
||||
import { SpeechCreateParams } from 'openai/resources/audio/speech.mjs';
|
||||
import { isKokoroModel } from '@/lib/shared/kokoro';
|
||||
import { LRUCache } from 'lru-cache';
|
||||
import { createHash } from 'crypto';
|
||||
import type { TTSRequestPayload } from '@/types/client';
|
||||
import type { TTSError, TTSAudioBuffer } from '@/types/tts';
|
||||
import type { TTSError } from '@/types/tts';
|
||||
import { headers } from 'next/headers';
|
||||
import { auth } from '@/lib/server/auth/auth';
|
||||
import { rateLimiter, RATE_LIMITS, isTtsRateLimitEnabled } from '@/lib/server/rate-limit/rate-limiter';
|
||||
import { isAuthEnabled } from '@/lib/server/auth/config';
|
||||
import { getClientIp } from '@/lib/server/rate-limit/request-ip';
|
||||
import { getOrCreateDeviceId, setDeviceIdCookie } from '@/lib/server/rate-limit/device-id';
|
||||
import { getOpenReaderTestNamespace } from '@/lib/server/testing/test-namespace';
|
||||
import {
|
||||
buildTTSCacheKey,
|
||||
generateTTSBuffer,
|
||||
getCachedTTSBuffer,
|
||||
getTTSContentType,
|
||||
} from '@/lib/server/tts/generate';
|
||||
|
||||
export const runtime = 'nodejs';
|
||||
export const maxDuration = 60;
|
||||
|
|
@ -22,30 +24,6 @@ function attachDeviceIdCookie(response: NextResponse, deviceId: string | null, d
|
|||
}
|
||||
}
|
||||
|
||||
type CustomVoice = string;
|
||||
type ExtendedSpeechParams = Omit<SpeechCreateParams, 'voice'> & {
|
||||
voice: SpeechCreateParams['voice'] | CustomVoice;
|
||||
instructions?: string;
|
||||
};
|
||||
type AudioBufferValue = TTSAudioBuffer;
|
||||
|
||||
const TTS_CACHE_MAX_SIZE_BYTES = Number(process.env.TTS_CACHE_MAX_SIZE_BYTES || 256 * 1024 * 1024); // 256MB
|
||||
const TTS_CACHE_TTL_MS = Number(process.env.TTS_CACHE_TTL_MS || 1000 * 60 * 30); // 30 minutes
|
||||
|
||||
const ttsAudioCache = new LRUCache<string, AudioBufferValue>({
|
||||
maxSize: TTS_CACHE_MAX_SIZE_BYTES,
|
||||
sizeCalculation: (value) => value.byteLength,
|
||||
ttl: TTS_CACHE_TTL_MS,
|
||||
});
|
||||
|
||||
type InflightEntry = {
|
||||
promise: Promise<TTSAudioBuffer>;
|
||||
controller: AbortController;
|
||||
consumers: number;
|
||||
};
|
||||
|
||||
const inflightRequests = new Map<string, InflightEntry>();
|
||||
|
||||
const PROBLEM_TYPES = {
|
||||
dailyQuotaExceeded: 'https://openreader.app/problems/daily-quota-exceeded',
|
||||
upstreamRateLimited: 'https://openreader.app/problems/upstream-rate-limited',
|
||||
|
|
@ -61,71 +39,6 @@ type ProblemDetails = {
|
|||
[key: string]: unknown;
|
||||
};
|
||||
|
||||
function sleep(ms: number) {
|
||||
return new Promise((res) => setTimeout(res, ms));
|
||||
}
|
||||
|
||||
async function fetchTTSBufferWithRetry(
|
||||
openai: OpenAI,
|
||||
createParams: ExtendedSpeechParams,
|
||||
signal: AbortSignal
|
||||
): Promise<TTSAudioBuffer> {
|
||||
let attempt = 0;
|
||||
const maxRetries = Number(process.env.TTS_MAX_RETRIES ?? 2);
|
||||
let delay = Number(process.env.TTS_RETRY_INITIAL_MS ?? 250);
|
||||
const maxDelay = Number(process.env.TTS_RETRY_MAX_MS ?? 2000);
|
||||
const backoff = Number(process.env.TTS_RETRY_BACKOFF ?? 2);
|
||||
|
||||
// Retry on 429 and 5xx only; never retry aborts
|
||||
for (; ;) {
|
||||
try {
|
||||
const response = await openai.audio.speech.create(createParams as SpeechCreateParams, { signal });
|
||||
return await response.arrayBuffer();
|
||||
} catch (err: unknown) {
|
||||
if (signal?.aborted || (err instanceof Error && err.name === 'AbortError')) {
|
||||
throw err;
|
||||
}
|
||||
const status = (() => {
|
||||
if (typeof err === 'object' && err !== null) {
|
||||
const rec = err as Record<string, unknown>;
|
||||
if (typeof rec.status === 'number') return rec.status as number;
|
||||
if (typeof rec.statusCode === 'number') return rec.statusCode as number;
|
||||
}
|
||||
return 0;
|
||||
})();
|
||||
const retryable = status === 429 || status >= 500;
|
||||
if (!retryable || attempt >= maxRetries) {
|
||||
throw err;
|
||||
}
|
||||
await sleep(Math.min(delay, maxDelay));
|
||||
delay = Math.min(maxDelay, delay * backoff);
|
||||
attempt += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function makeCacheKey(input: {
|
||||
provider: string;
|
||||
model: string | null | undefined;
|
||||
voice: string | undefined;
|
||||
speed: number;
|
||||
format: string;
|
||||
text: string;
|
||||
instructions?: string;
|
||||
}) {
|
||||
const canonical = {
|
||||
provider: input.provider,
|
||||
model: input.model || '',
|
||||
voice: input.voice || '',
|
||||
speed: input.speed,
|
||||
format: input.format,
|
||||
text: input.text,
|
||||
// Only include instructions when present (for models like gpt-4o-mini-tts)
|
||||
instructions: input.instructions || undefined,
|
||||
};
|
||||
return createHash('sha256').update(JSON.stringify(canonical)).digest('hex');
|
||||
}
|
||||
|
||||
function formatLimitForHint(limit: number): string {
|
||||
if (!Number.isFinite(limit) || limit <= 0) return String(limit);
|
||||
if (limit >= 1_000_000) {
|
||||
|
|
@ -136,12 +49,22 @@ function formatLimitForHint(limit: number): string {
|
|||
return String(limit);
|
||||
}
|
||||
|
||||
function getUpstreamStatus(error: unknown): number | undefined {
|
||||
if (typeof error !== 'object' || error === null) return undefined;
|
||||
const rec = error as Record<string, unknown>;
|
||||
if (typeof rec.status === 'number') return rec.status;
|
||||
if (typeof rec.statusCode === 'number') return rec.statusCode;
|
||||
return undefined;
|
||||
}
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
let providerForError: string | null = null;
|
||||
let didCreateDeviceIdCookie = false;
|
||||
let deviceIdToSet: string | null = null;
|
||||
|
||||
try {
|
||||
// Parse body first to get text for rate limiting
|
||||
const body = (await req.json()) as TTSRequestPayload;
|
||||
const { text, voice, speed, format, model: req_model, instructions } = body;
|
||||
const { text, voice, speed, format, model: reqModel, instructions } = body;
|
||||
|
||||
if (!text || !voice || !speed) {
|
||||
const errorBody: TTSError = {
|
||||
|
|
@ -151,13 +74,9 @@ export async function POST(req: NextRequest) {
|
|||
return NextResponse.json(errorBody, { status: 400 });
|
||||
}
|
||||
|
||||
// Auth and TTS char rate limiting check (only when auth is enabled)
|
||||
let didCreateDeviceIdCookie = false;
|
||||
let deviceIdToSet: string | null = null;
|
||||
|
||||
if (isAuthEnabled() && auth) {
|
||||
const session = await auth.api.getSession({
|
||||
headers: await headers()
|
||||
headers: await headers(),
|
||||
});
|
||||
|
||||
if (!session?.user) {
|
||||
|
|
@ -177,7 +96,6 @@ export async function POST(req: NextRequest) {
|
|||
deviceIdToSet = device.deviceId;
|
||||
}
|
||||
|
||||
// Check rate limit
|
||||
const rateLimitResult = await rateLimiter.checkAndIncrementLimit(
|
||||
{ id: session.user.id, isAnonymous },
|
||||
charCount,
|
||||
|
|
@ -220,204 +138,88 @@ export async function POST(req: NextRequest) {
|
|||
});
|
||||
|
||||
attachDeviceIdCookie(response, deviceIdToSet, didCreateDeviceIdCookie);
|
||||
|
||||
return response;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Get API credentials from headers or fall back to environment variables
|
||||
const openApiKey = req.headers.get('x-openai-key') || process.env.API_KEY || 'none';
|
||||
const openApiBaseUrl = req.headers.get('x-openai-base-url') || process.env.API_BASE;
|
||||
const provider = req.headers.get('x-tts-provider') || 'openai';
|
||||
const testNamespace = getOpenReaderTestNamespace(req.headers);
|
||||
providerForError = provider;
|
||||
console.log('Received TTS request:', { provider, req_model, voice, speed, format, hasInstructions: Boolean(instructions) });
|
||||
// Use default Kokoro model for Deepinfra if none specified, then fall back to a safe default
|
||||
const rawModel = provider === 'deepinfra' && !req_model ? 'hexgrad/Kokoro-82M' : req_model;
|
||||
const model: SpeechCreateParams['model'] = (rawModel ?? 'gpt-4o-mini-tts') as SpeechCreateParams['model'];
|
||||
const varyHeader = 'x-tts-provider, x-openai-key, x-openai-base-url, x-openreader-test-namespace';
|
||||
|
||||
// Initialize OpenAI client with abort signal (OpenAI/deepinfra)
|
||||
const openai = new OpenAI({
|
||||
apiKey: openApiKey,
|
||||
baseURL: openApiBaseUrl,
|
||||
// Keep retry policy centralized in this route's fetchTTSBufferWithRetry.
|
||||
maxRetries: 0,
|
||||
// Keep upstream timeout below route max duration so we can return a controlled error.
|
||||
timeout: Number(process.env.TTS_UPSTREAM_TIMEOUT_MS ?? 45_000),
|
||||
});
|
||||
|
||||
const normalizedVoice = (
|
||||
!isKokoroModel(model as string) && voice.includes('+')
|
||||
? (voice.split('+')[0].trim())
|
||||
: voice
|
||||
) as SpeechCreateParams['voice'];
|
||||
|
||||
const resolvedFormat = format || 'mp3';
|
||||
|
||||
const createParams: ExtendedSpeechParams = {
|
||||
model: model,
|
||||
voice: normalizedVoice,
|
||||
input: text,
|
||||
speed: speed,
|
||||
response_format: resolvedFormat,
|
||||
};
|
||||
// Only add instructions if model is gpt-4o-mini-tts and instructions are provided
|
||||
if ((model as string) === 'gpt-4o-mini-tts' && instructions) {
|
||||
createParams.instructions = instructions;
|
||||
}
|
||||
|
||||
// Compute cache key and check LRU before making provider call
|
||||
const contentType = 'audio/mpeg';
|
||||
|
||||
// Preserve voice string as-is for cache key (no weight stripping)
|
||||
const voiceForKey = typeof createParams.voice === 'string'
|
||||
? createParams.voice
|
||||
: String(createParams.voice);
|
||||
|
||||
const cacheKey = makeCacheKey({
|
||||
provider,
|
||||
model: createParams.model,
|
||||
voice: voiceForKey,
|
||||
speed: Number(createParams.speed),
|
||||
format: String(createParams.response_format),
|
||||
const ttsRequest = {
|
||||
text,
|
||||
instructions: createParams.instructions,
|
||||
});
|
||||
voice,
|
||||
speed,
|
||||
format,
|
||||
model: reqModel,
|
||||
instructions,
|
||||
provider,
|
||||
apiKey: openApiKey,
|
||||
baseUrl: openApiBaseUrl,
|
||||
testNamespace,
|
||||
};
|
||||
|
||||
const contentType = getTTSContentType(format);
|
||||
const cacheKey = buildTTSCacheKey(ttsRequest);
|
||||
const etag = `W/"${cacheKey}"`;
|
||||
const ifNoneMatch = req.headers.get('if-none-match');
|
||||
|
||||
const cachedBuffer = ttsAudioCache.get(cacheKey);
|
||||
const cachedBuffer = getCachedTTSBuffer(cacheKey);
|
||||
if (cachedBuffer) {
|
||||
if (ifNoneMatch && (ifNoneMatch.includes(cacheKey) || ifNoneMatch.includes(etag))) {
|
||||
const response = new NextResponse(null, {
|
||||
status: 304,
|
||||
headers: {
|
||||
'ETag': etag,
|
||||
ETag: etag,
|
||||
'Cache-Control': 'private, max-age=1800',
|
||||
'Vary': 'x-tts-provider, x-openai-key, x-openai-base-url'
|
||||
}
|
||||
Vary: varyHeader,
|
||||
},
|
||||
});
|
||||
|
||||
attachDeviceIdCookie(response, deviceIdToSet, didCreateDeviceIdCookie);
|
||||
|
||||
return response;
|
||||
}
|
||||
console.log('TTS cache HIT for key:', cacheKey.slice(0, 8));
|
||||
const response = new NextResponse(cachedBuffer, {
|
||||
|
||||
const response = new NextResponse(new Uint8Array(cachedBuffer), {
|
||||
headers: {
|
||||
'Content-Type': contentType,
|
||||
'X-Cache': 'HIT',
|
||||
'ETag': etag,
|
||||
ETag: etag,
|
||||
'Content-Length': String(cachedBuffer.byteLength),
|
||||
'Cache-Control': 'private, max-age=1800',
|
||||
'Vary': 'x-tts-provider, x-openai-key, x-openai-base-url'
|
||||
}
|
||||
Vary: varyHeader,
|
||||
},
|
||||
});
|
||||
|
||||
attachDeviceIdCookie(response, deviceIdToSet, didCreateDeviceIdCookie);
|
||||
|
||||
return response;
|
||||
}
|
||||
|
||||
// De-duplicate identical in-flight requests
|
||||
const existing = inflightRequests.get(cacheKey);
|
||||
if (existing) {
|
||||
console.log('TTS in-flight JOIN for key:', cacheKey.slice(0, 8));
|
||||
existing.consumers += 1;
|
||||
const buffer = await generateTTSBuffer(ttsRequest, req.signal);
|
||||
|
||||
const onAbort = () => {
|
||||
existing.consumers = Math.max(0, existing.consumers - 1);
|
||||
if (existing.consumers === 0) {
|
||||
existing.controller.abort();
|
||||
}
|
||||
};
|
||||
req.signal.addEventListener('abort', onAbort, { once: true });
|
||||
|
||||
try {
|
||||
const buffer = await existing.promise;
|
||||
const response = new NextResponse(buffer, {
|
||||
headers: {
|
||||
'Content-Type': contentType,
|
||||
'X-Cache': 'INFLIGHT',
|
||||
'ETag': etag,
|
||||
'Content-Length': String(buffer.byteLength),
|
||||
'Cache-Control': 'private, max-age=1800',
|
||||
'Vary': 'x-tts-provider, x-openai-key, x-openai-base-url'
|
||||
}
|
||||
});
|
||||
|
||||
attachDeviceIdCookie(response, deviceIdToSet, didCreateDeviceIdCookie);
|
||||
|
||||
return response;
|
||||
} finally {
|
||||
try { req.signal.removeEventListener('abort', onAbort); } catch { }
|
||||
}
|
||||
}
|
||||
|
||||
const controller = new AbortController();
|
||||
const entry: InflightEntry = {
|
||||
controller,
|
||||
consumers: 1,
|
||||
promise: (async () => {
|
||||
try {
|
||||
const buffer = await fetchTTSBufferWithRetry(openai, createParams, controller.signal);
|
||||
// Save to cache
|
||||
ttsAudioCache.set(cacheKey, buffer);
|
||||
return buffer;
|
||||
} finally {
|
||||
inflightRequests.delete(cacheKey);
|
||||
}
|
||||
})()
|
||||
};
|
||||
|
||||
inflightRequests.set(cacheKey, entry);
|
||||
|
||||
const onAbort = () => {
|
||||
entry.consumers = Math.max(0, entry.consumers - 1);
|
||||
if (entry.consumers === 0) {
|
||||
entry.controller.abort();
|
||||
}
|
||||
};
|
||||
req.signal.addEventListener('abort', onAbort, { once: true });
|
||||
|
||||
let buffer: TTSAudioBuffer;
|
||||
try {
|
||||
buffer = await entry.promise;
|
||||
} finally {
|
||||
try { req.signal.removeEventListener('abort', onAbort); } catch { }
|
||||
}
|
||||
|
||||
const response = new NextResponse(buffer, {
|
||||
const response = new NextResponse(new Uint8Array(buffer), {
|
||||
headers: {
|
||||
'Content-Type': contentType,
|
||||
'X-Cache': 'MISS',
|
||||
'ETag': etag,
|
||||
ETag: etag,
|
||||
'Content-Length': String(buffer.byteLength),
|
||||
'Cache-Control': 'private, max-age=1800',
|
||||
'Vary': 'x-tts-provider, x-openai-key, x-openai-base-url'
|
||||
}
|
||||
Vary: varyHeader,
|
||||
},
|
||||
});
|
||||
|
||||
attachDeviceIdCookie(response, deviceIdToSet, didCreateDeviceIdCookie);
|
||||
|
||||
return response;
|
||||
} catch (error) {
|
||||
// Check if this was an abort error
|
||||
if (error instanceof Error && error.name === 'AbortError') {
|
||||
console.log('TTS request aborted by client');
|
||||
return new NextResponse(null, { status: 499 }); // Use 499 status for client closed request
|
||||
return new NextResponse(null, { status: 499 });
|
||||
}
|
||||
|
||||
const upstreamStatus = (() => {
|
||||
if (typeof error === 'object' && error !== null) {
|
||||
const rec = error as Record<string, unknown>;
|
||||
if (typeof rec.status === 'number') return rec.status as number;
|
||||
if (typeof rec.statusCode === 'number') return rec.statusCode as number;
|
||||
}
|
||||
return undefined;
|
||||
})();
|
||||
|
||||
const upstreamStatus = getUpstreamStatus(error);
|
||||
if (upstreamStatus === 429) {
|
||||
const problem: ProblemDetails = {
|
||||
type: PROBLEM_TYPES.upstreamRateLimited,
|
||||
|
|
@ -444,9 +246,6 @@ export async function POST(req: NextRequest) {
|
|||
message: 'Failed to generate audio',
|
||||
details: process.env.NODE_ENV !== 'production' ? String(error) : undefined,
|
||||
};
|
||||
return NextResponse.json(
|
||||
errorBody,
|
||||
{ status: 500 }
|
||||
);
|
||||
return NextResponse.json(errorBody, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -47,7 +47,7 @@ export function AudiobookExportModal({
|
|||
onGenerateAudiobook,
|
||||
onRegenerateChapter
|
||||
}: AudiobookExportModalProps) {
|
||||
const { isLoading, isDBReady, ttsProvider, ttsModel, voice: configVoice, voiceSpeed, audioPlayerSpeed } = useConfig();
|
||||
const { isLoading, isDBReady, ttsProvider, ttsModel, ttsInstructions, voice: configVoice, voiceSpeed, audioPlayerSpeed } = useConfig();
|
||||
const { availableVoices } = useTTS();
|
||||
const { progress, setProgress, estimatedTimeRemaining } = useTimeEstimation();
|
||||
const [isGenerating, setIsGenerating] = useState(false);
|
||||
|
|
@ -115,8 +115,9 @@ export function AudiobookExportModal({
|
|||
nativeSpeed,
|
||||
postSpeed,
|
||||
format,
|
||||
ttsInstructions: ttsModel === 'gpt-4o-mini-tts' ? ttsInstructions : undefined,
|
||||
};
|
||||
}, [savedSettings, audiobookVoice, configVoice, availableVoices, ttsProvider, ttsModel, nativeSpeed, postSpeed, format]);
|
||||
}, [savedSettings, audiobookVoice, configVoice, availableVoices, ttsProvider, ttsModel, ttsInstructions, nativeSpeed, postSpeed, format]);
|
||||
|
||||
const fetchExistingChapters = useCallback(async (soft: boolean = false) => {
|
||||
if (soft) {
|
||||
|
|
|
|||
|
|
@ -25,7 +25,7 @@ import { useAuthConfig } from '@/contexts/AuthRateLimitContext';
|
|||
import { createRangeCfi } from '@/lib/client/epub';
|
||||
import { useParams } from 'next/navigation';
|
||||
import { useConfig } from './ConfigContext';
|
||||
import { withRetry, getAudiobookStatus, generateTTS, createAudiobookChapter } from '@/lib/client/api/audiobooks';
|
||||
import { withRetry, getAudiobookStatus, createAudiobookChapter } from '@/lib/client/api/audiobooks';
|
||||
import { CmpStr } from 'cmpstr';
|
||||
import type {
|
||||
TTSSentenceAlignment,
|
||||
|
|
@ -34,7 +34,6 @@ import type {
|
|||
} from '@/types/tts';
|
||||
import type {
|
||||
TTSRequestHeaders,
|
||||
TTSRequestPayload,
|
||||
TTSRetryOptions,
|
||||
AudiobookGenerationSettings,
|
||||
} from '@/types/client';
|
||||
|
|
@ -179,11 +178,7 @@ export function EPUBProvider({ children }: { children: ReactNode }) {
|
|||
const {
|
||||
apiKey,
|
||||
baseUrl,
|
||||
voiceSpeed,
|
||||
voice,
|
||||
ttsProvider,
|
||||
ttsModel,
|
||||
ttsInstructions,
|
||||
smartSentenceSplitting,
|
||||
epubHighlightEnabled,
|
||||
} = useConfig();
|
||||
|
|
@ -358,16 +353,6 @@ export function EPUBProvider({ children }: { children: ReactNode }) {
|
|||
if (!sections.length) throw new Error('No text content found in book');
|
||||
|
||||
const effectiveProvider = settings?.ttsProvider ?? ttsProvider;
|
||||
const effectiveModel = settings?.ttsModel ?? ttsModel;
|
||||
const effectiveVoice =
|
||||
settings?.voice ||
|
||||
voice ||
|
||||
(effectiveProvider === 'openai'
|
||||
? 'alloy'
|
||||
: effectiveProvider === 'deepinfra'
|
||||
? 'af_bella'
|
||||
: 'af_sarah');
|
||||
const effectiveNativeSpeed = settings?.nativeSpeed ?? voiceSpeed;
|
||||
const effectiveFormat = settings?.format ?? format;
|
||||
|
||||
// Calculate total length for accurate progress tracking
|
||||
|
|
@ -448,16 +433,7 @@ export function EPUBProvider({ children }: { children: ReactNode }) {
|
|||
'x-tts-provider': effectiveProvider,
|
||||
};
|
||||
|
||||
const reqBody: TTSRequestPayload = {
|
||||
text: trimmedText,
|
||||
voice: effectiveVoice,
|
||||
speed: effectiveNativeSpeed,
|
||||
format: 'mp3',
|
||||
model: effectiveModel,
|
||||
instructions: effectiveModel === 'gpt-4o-mini-tts' ? ttsInstructions : undefined
|
||||
};
|
||||
|
||||
// Allow one narrow client retry for transient browser->/api/tts transport failures.
|
||||
// Allow one narrow client retry for transient browser->/api/audiobook/chapter transport failures.
|
||||
// HTTP failures are not retried client-side.
|
||||
const retryOptions: TTSRetryOptions = {
|
||||
maxRetries: 2,
|
||||
|
|
@ -465,18 +441,6 @@ export function EPUBProvider({ children }: { children: ReactNode }) {
|
|||
maxDelay: 300,
|
||||
};
|
||||
|
||||
const audioBuffer = await withRetry(
|
||||
async () => {
|
||||
// Check for abort before starting TTS request
|
||||
if (signal?.aborted) {
|
||||
throw new DOMException('Aborted', 'AbortError');
|
||||
}
|
||||
|
||||
return await generateTTS(reqBody, reqHeaders, signal);
|
||||
},
|
||||
retryOptions
|
||||
);
|
||||
|
||||
// Get the chapter title from our pre-computed map
|
||||
let chapterTitle = sectionTitleMap.get(section.href);
|
||||
|
||||
|
|
@ -485,6 +449,25 @@ export function EPUBProvider({ children }: { children: ReactNode }) {
|
|||
chapterTitle = `Chapter ${i + 1}`;
|
||||
}
|
||||
|
||||
const chapter = await withRetry(
|
||||
async () => {
|
||||
// Check for abort before starting chapter request
|
||||
if (signal?.aborted) {
|
||||
throw new DOMException('Aborted', 'AbortError');
|
||||
}
|
||||
|
||||
return await createAudiobookChapter({
|
||||
chapterTitle,
|
||||
text: trimmedText,
|
||||
bookId,
|
||||
format: effectiveFormat,
|
||||
chapterIndex: i,
|
||||
settings
|
||||
}, reqHeaders, signal);
|
||||
},
|
||||
retryOptions
|
||||
);
|
||||
|
||||
// Check for abort before sending to server
|
||||
if (signal?.aborted) {
|
||||
console.log('Generation cancelled before saving chapter');
|
||||
|
|
@ -494,16 +477,6 @@ export function EPUBProvider({ children }: { children: ReactNode }) {
|
|||
throw new Error('Audiobook generation cancelled');
|
||||
}
|
||||
|
||||
// Send to server for conversion and storage
|
||||
const chapter = await createAudiobookChapter({
|
||||
chapterTitle,
|
||||
buffer: Array.from(new Uint8Array(audioBuffer)),
|
||||
bookId,
|
||||
format: effectiveFormat,
|
||||
chapterIndex: i,
|
||||
settings
|
||||
}, signal);
|
||||
|
||||
if (!bookId) {
|
||||
bookId = chapter.bookId!;
|
||||
}
|
||||
|
|
@ -548,7 +521,7 @@ export function EPUBProvider({ children }: { children: ReactNode }) {
|
|||
console.error('Error creating audiobook:', error);
|
||||
throw error;
|
||||
}
|
||||
}, [extractBookText, apiKey, baseUrl, voice, voiceSpeed, ttsProvider, ttsModel, ttsInstructions]);
|
||||
}, [extractBookText, apiKey, baseUrl, ttsProvider]);
|
||||
|
||||
/**
|
||||
* Regenerates a specific chapter of the audiobook
|
||||
|
|
@ -567,16 +540,6 @@ export function EPUBProvider({ children }: { children: ReactNode }) {
|
|||
}
|
||||
|
||||
const effectiveProvider = settings?.ttsProvider ?? ttsProvider;
|
||||
const effectiveModel = settings?.ttsModel ?? ttsModel;
|
||||
const effectiveVoice =
|
||||
settings?.voice ||
|
||||
voice ||
|
||||
(effectiveProvider === 'openai'
|
||||
? 'alloy'
|
||||
: effectiveProvider === 'deepinfra'
|
||||
? 'af_bella'
|
||||
: 'af_sarah');
|
||||
const effectiveNativeSpeed = settings?.nativeSpeed ?? voiceSpeed;
|
||||
const effectiveFormat = settings?.format ?? format;
|
||||
|
||||
const section = sections[chapterIndex];
|
||||
|
|
@ -615,16 +578,7 @@ export function EPUBProvider({ children }: { children: ReactNode }) {
|
|||
'x-tts-provider': effectiveProvider,
|
||||
};
|
||||
|
||||
const reqBody: TTSRequestPayload = {
|
||||
text: trimmedText,
|
||||
voice: effectiveVoice,
|
||||
speed: effectiveNativeSpeed,
|
||||
format: 'mp3',
|
||||
model: effectiveModel,
|
||||
instructions: effectiveModel === 'gpt-4o-mini-tts' ? ttsInstructions : undefined
|
||||
};
|
||||
|
||||
// Allow one narrow client retry for transient browser->/api/tts transport failures.
|
||||
// Allow one narrow client retry for transient browser->/api/audiobook/chapter transport failures.
|
||||
// HTTP failures are not retried client-side.
|
||||
const retryOptions: TTSRetryOptions = {
|
||||
maxRetries: 2,
|
||||
|
|
@ -632,13 +586,20 @@ export function EPUBProvider({ children }: { children: ReactNode }) {
|
|||
maxDelay: 300,
|
||||
};
|
||||
|
||||
const audioBuffer = await withRetry(
|
||||
const chapter = await withRetry(
|
||||
async () => {
|
||||
if (signal?.aborted) {
|
||||
throw new DOMException('Aborted', 'AbortError');
|
||||
}
|
||||
|
||||
return await generateTTS(reqBody, reqHeaders, signal);
|
||||
return await createAudiobookChapter({
|
||||
chapterTitle,
|
||||
text: trimmedText,
|
||||
bookId,
|
||||
format: effectiveFormat,
|
||||
chapterIndex,
|
||||
settings
|
||||
}, reqHeaders, signal);
|
||||
},
|
||||
retryOptions
|
||||
);
|
||||
|
|
@ -647,16 +608,6 @@ export function EPUBProvider({ children }: { children: ReactNode }) {
|
|||
throw new Error('Chapter regeneration cancelled');
|
||||
}
|
||||
|
||||
// Send to server for conversion and storage
|
||||
const chapter = await createAudiobookChapter({
|
||||
chapterTitle,
|
||||
buffer: Array.from(new Uint8Array(audioBuffer)),
|
||||
bookId,
|
||||
format: effectiveFormat,
|
||||
chapterIndex,
|
||||
settings
|
||||
}, signal);
|
||||
|
||||
return chapter;
|
||||
|
||||
} catch (error) {
|
||||
|
|
@ -666,7 +617,7 @@ export function EPUBProvider({ children }: { children: ReactNode }) {
|
|||
console.error('Error regenerating chapter:', error);
|
||||
throw error;
|
||||
}
|
||||
}, [extractBookText, apiKey, baseUrl, voice, voiceSpeed, ttsProvider, ttsModel, ttsInstructions]);
|
||||
}, [extractBookText, apiKey, baseUrl, ttsProvider]);
|
||||
|
||||
const setRendition = useCallback((rendition: Rendition) => {
|
||||
bookRef.current = rendition.book;
|
||||
|
|
|
|||
|
|
@ -32,7 +32,7 @@ import { ensureCachedDocument } from '@/lib/client/cache/documents';
|
|||
import { useTTS } from '@/contexts/TTSContext';
|
||||
import { useConfig } from '@/contexts/ConfigContext';
|
||||
import { normalizeTextForTts } from '@/lib/shared/nlp';
|
||||
import { withRetry, getAudiobookStatus, generateTTS, createAudiobookChapter } from '@/lib/client/api/audiobooks';
|
||||
import { withRetry, getAudiobookStatus, createAudiobookChapter } from '@/lib/client/api/audiobooks';
|
||||
import {
|
||||
extractTextFromPDF,
|
||||
highlightPattern,
|
||||
|
|
@ -43,13 +43,11 @@ import {
|
|||
|
||||
import type {
|
||||
TTSSentenceAlignment,
|
||||
TTSAudioBuffer,
|
||||
TTSAudiobookFormat,
|
||||
TTSAudiobookChapter,
|
||||
} from '@/types/tts';
|
||||
import type {
|
||||
TTSRequestHeaders,
|
||||
TTSRequestPayload,
|
||||
TTSRetryOptions,
|
||||
AudiobookGenerationSettings,
|
||||
} from '@/types/client';
|
||||
|
|
@ -130,11 +128,7 @@ export function PDFProvider({ children }: { children: ReactNode }) {
|
|||
rightMargin,
|
||||
apiKey,
|
||||
baseUrl,
|
||||
voiceSpeed,
|
||||
voice,
|
||||
ttsProvider,
|
||||
ttsModel,
|
||||
ttsInstructions,
|
||||
smartSentenceSplitting,
|
||||
} = useConfig();
|
||||
|
||||
|
|
@ -420,16 +414,6 @@ export function PDFProvider({ children }: { children: ReactNode }) {
|
|||
}
|
||||
|
||||
const effectiveProvider = settings?.ttsProvider ?? ttsProvider;
|
||||
const effectiveModel = settings?.ttsModel ?? ttsModel;
|
||||
const effectiveVoice =
|
||||
settings?.voice ||
|
||||
voice ||
|
||||
(effectiveProvider === 'openai'
|
||||
? 'alloy'
|
||||
: effectiveProvider === 'deepinfra'
|
||||
? 'af_bella'
|
||||
: 'af_sarah');
|
||||
const effectiveNativeSpeed = settings?.nativeSpeed ?? voiceSpeed;
|
||||
const effectiveFormat = settings?.format ?? format;
|
||||
|
||||
// First pass: extract and measure all text
|
||||
|
|
@ -504,16 +488,7 @@ export function PDFProvider({ children }: { children: ReactNode }) {
|
|||
'x-tts-provider': effectiveProvider,
|
||||
};
|
||||
|
||||
const reqBody: TTSRequestPayload = {
|
||||
text,
|
||||
voice: effectiveVoice,
|
||||
speed: effectiveNativeSpeed,
|
||||
format: 'mp3',
|
||||
model: effectiveModel,
|
||||
instructions: effectiveModel === 'gpt-4o-mini-tts' ? ttsInstructions : undefined
|
||||
};
|
||||
|
||||
// Allow one narrow client retry for transient browser->/api/tts transport failures.
|
||||
// Allow one narrow client retry for transient browser->/api/audiobook/chapter transport failures.
|
||||
// HTTP failures are not retried client-side.
|
||||
const retryOptions: TTSRetryOptions = {
|
||||
maxRetries: 2,
|
||||
|
|
@ -522,20 +497,27 @@ export function PDFProvider({ children }: { children: ReactNode }) {
|
|||
};
|
||||
|
||||
try {
|
||||
const audioBuffer = await withRetry(
|
||||
const chapterTitle = `Page ${i + 1}`;
|
||||
|
||||
const chapter = await withRetry(
|
||||
async () => {
|
||||
// Check for abort before starting TTS request
|
||||
// Check for abort before starting chapter request
|
||||
if (signal?.aborted) {
|
||||
throw new DOMException('Aborted', 'AbortError');
|
||||
}
|
||||
|
||||
return await generateTTS(reqBody, reqHeaders, signal);
|
||||
return await createAudiobookChapter({
|
||||
chapterTitle,
|
||||
text,
|
||||
bookId,
|
||||
format: effectiveFormat,
|
||||
chapterIndex: i,
|
||||
settings
|
||||
}, reqHeaders, signal);
|
||||
},
|
||||
retryOptions
|
||||
);
|
||||
|
||||
const chapterTitle = `Page ${i + 1}`;
|
||||
|
||||
// Check for abort before sending to server
|
||||
if (signal?.aborted) {
|
||||
console.log('Generation cancelled before saving page');
|
||||
|
|
@ -545,16 +527,6 @@ export function PDFProvider({ children }: { children: ReactNode }) {
|
|||
throw new Error('Audiobook generation cancelled');
|
||||
}
|
||||
|
||||
// Send to server for conversion and storage
|
||||
const chapter = await createAudiobookChapter({
|
||||
chapterTitle,
|
||||
buffer: Array.from(new Uint8Array(audioBuffer)),
|
||||
bookId,
|
||||
format: effectiveFormat,
|
||||
chapterIndex: i,
|
||||
settings
|
||||
}, signal);
|
||||
|
||||
if (!bookId) {
|
||||
bookId = chapter.bookId!;
|
||||
}
|
||||
|
|
@ -599,7 +571,7 @@ export function PDFProvider({ children }: { children: ReactNode }) {
|
|||
console.error('Error creating audiobook:', error);
|
||||
throw error;
|
||||
}
|
||||
}, [pdfDocument, headerMargin, footerMargin, leftMargin, rightMargin, apiKey, baseUrl, voice, voiceSpeed, ttsProvider, ttsModel, ttsInstructions, smartSentenceSplitting]);
|
||||
}, [pdfDocument, headerMargin, footerMargin, leftMargin, rightMargin, apiKey, baseUrl, ttsProvider, smartSentenceSplitting]);
|
||||
|
||||
/**
|
||||
* Regenerates a specific chapter (page) of the PDF audiobook
|
||||
|
|
@ -617,16 +589,6 @@ export function PDFProvider({ children }: { children: ReactNode }) {
|
|||
}
|
||||
|
||||
const effectiveProvider = settings?.ttsProvider ?? ttsProvider;
|
||||
const effectiveModel = settings?.ttsModel ?? ttsModel;
|
||||
const effectiveVoice =
|
||||
settings?.voice ||
|
||||
voice ||
|
||||
(effectiveProvider === 'openai'
|
||||
? 'alloy'
|
||||
: effectiveProvider === 'deepinfra'
|
||||
? 'af_bella'
|
||||
: 'af_sarah');
|
||||
const effectiveNativeSpeed = settings?.nativeSpeed ?? voiceSpeed;
|
||||
const effectiveFormat = settings?.format ?? format;
|
||||
|
||||
// IMPORTANT: Chapter indices are based on non-empty pages used during generation.
|
||||
|
|
@ -678,16 +640,7 @@ export function PDFProvider({ children }: { children: ReactNode }) {
|
|||
'x-tts-provider': effectiveProvider,
|
||||
};
|
||||
|
||||
const reqBody: TTSRequestPayload = {
|
||||
text: textForTTS,
|
||||
voice: effectiveVoice,
|
||||
speed: effectiveNativeSpeed,
|
||||
format: 'mp3',
|
||||
model: effectiveModel,
|
||||
instructions: effectiveModel === 'gpt-4o-mini-tts' ? ttsInstructions : undefined
|
||||
};
|
||||
|
||||
// Allow one narrow client retry for transient browser->/api/tts transport failures.
|
||||
// Allow one narrow client retry for transient browser->/api/audiobook/chapter transport failures.
|
||||
// HTTP failures are not retried client-side.
|
||||
const retryOptions: TTSRetryOptions = {
|
||||
maxRetries: 2,
|
||||
|
|
@ -695,13 +648,20 @@ export function PDFProvider({ children }: { children: ReactNode }) {
|
|||
maxDelay: 300,
|
||||
};
|
||||
|
||||
const audioBuffer: TTSAudioBuffer = await withRetry(
|
||||
const chapter = await withRetry(
|
||||
async () => {
|
||||
if (signal?.aborted) {
|
||||
throw new DOMException('Aborted', 'AbortError');
|
||||
}
|
||||
|
||||
return await generateTTS(reqBody, reqHeaders, signal);
|
||||
return await createAudiobookChapter({
|
||||
chapterTitle,
|
||||
text: textForTTS,
|
||||
bookId,
|
||||
format: effectiveFormat,
|
||||
chapterIndex,
|
||||
settings
|
||||
}, reqHeaders, signal);
|
||||
},
|
||||
retryOptions
|
||||
);
|
||||
|
|
@ -710,16 +670,6 @@ export function PDFProvider({ children }: { children: ReactNode }) {
|
|||
throw new Error('Page regeneration cancelled');
|
||||
}
|
||||
|
||||
// Send to server for conversion and storage
|
||||
const chapter = await createAudiobookChapter({
|
||||
chapterTitle,
|
||||
buffer: Array.from(new Uint8Array(audioBuffer)),
|
||||
bookId,
|
||||
format: effectiveFormat,
|
||||
chapterIndex,
|
||||
settings
|
||||
}, signal);
|
||||
|
||||
return chapter;
|
||||
|
||||
} catch (error) {
|
||||
|
|
@ -729,7 +679,7 @@ export function PDFProvider({ children }: { children: ReactNode }) {
|
|||
console.error('Error regenerating page:', error);
|
||||
throw error;
|
||||
}
|
||||
}, [pdfDocument, headerMargin, footerMargin, leftMargin, rightMargin, apiKey, baseUrl, voice, voiceSpeed, ttsProvider, ttsModel, ttsInstructions, smartSentenceSplitting]);
|
||||
}, [pdfDocument, headerMargin, footerMargin, leftMargin, rightMargin, apiKey, baseUrl, ttsProvider, smartSentenceSplitting]);
|
||||
|
||||
/**
|
||||
* Effect hook to initialize TTS as non-EPUB mode
|
||||
|
|
|
|||
|
|
@ -115,12 +115,14 @@ export const getAudiobookStatus = async (bookId: string): Promise<AudiobookStatu
|
|||
|
||||
export const createAudiobookChapter = async (
|
||||
payload: CreateChapterPayload,
|
||||
headers: TTSRequestHeaders,
|
||||
signal?: AbortSignal
|
||||
): Promise<TTSAudiobookChapter> => {
|
||||
const response = await fetch(`/api/audiobook/chapter`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
...headers,
|
||||
},
|
||||
body: JSON.stringify(payload),
|
||||
signal
|
||||
|
|
|
|||
296
src/lib/server/tts/generate.ts
Normal file
296
src/lib/server/tts/generate.ts
Normal file
|
|
@ -0,0 +1,296 @@
|
|||
import OpenAI from 'openai';
|
||||
import { SpeechCreateParams } from 'openai/resources/audio/speech.mjs';
|
||||
import { isKokoroModel } from '@/lib/shared/kokoro';
|
||||
import { LRUCache } from 'lru-cache';
|
||||
import { createHash } from 'crypto';
|
||||
import { access, readFile } from 'fs/promises';
|
||||
import { resolve } from 'path';
|
||||
|
||||
export interface ServerTTSRequest {
|
||||
text: string;
|
||||
voice: string;
|
||||
speed: number;
|
||||
format?: string;
|
||||
model?: string | null;
|
||||
instructions?: string;
|
||||
provider: string;
|
||||
apiKey: string;
|
||||
baseUrl?: string;
|
||||
testNamespace?: string | null;
|
||||
}
|
||||
|
||||
type CustomVoice = string;
|
||||
type ExtendedSpeechParams = Omit<SpeechCreateParams, 'voice'> & {
|
||||
voice: SpeechCreateParams['voice'] | CustomVoice;
|
||||
instructions?: string;
|
||||
};
|
||||
|
||||
type ResolvedServerTTSRequest = {
|
||||
text: string;
|
||||
voice: string;
|
||||
speed: number;
|
||||
format: string;
|
||||
model: SpeechCreateParams['model'];
|
||||
instructions?: string;
|
||||
provider: string;
|
||||
apiKey: string;
|
||||
baseUrl?: string;
|
||||
testNamespace?: string | null;
|
||||
};
|
||||
|
||||
type InflightEntry = {
|
||||
promise: Promise<Buffer>;
|
||||
controller: AbortController;
|
||||
consumers: number;
|
||||
};
|
||||
|
||||
const TTS_CACHE_MAX_SIZE_BYTES = Number(process.env.TTS_CACHE_MAX_SIZE_BYTES || 256 * 1024 * 1024); // 256MB
|
||||
const TTS_CACHE_TTL_MS = Number(process.env.TTS_CACHE_TTL_MS || 1000 * 60 * 30); // 30 minutes
|
||||
|
||||
const ttsAudioCache = new LRUCache<string, Buffer>({
|
||||
maxSize: TTS_CACHE_MAX_SIZE_BYTES,
|
||||
sizeCalculation: (value) => value.byteLength,
|
||||
ttl: TTS_CACHE_TTL_MS,
|
||||
});
|
||||
|
||||
const inflightRequests = new Map<string, InflightEntry>();
|
||||
|
||||
const TEST_TTS_MOCK_PATH = resolve(process.cwd(), 'tests/files/sample.mp3');
|
||||
let testMockTtsBufferPromise: Promise<Buffer | null> | null = null;
|
||||
|
||||
function sleep(ms: number) {
|
||||
return new Promise((res) => setTimeout(res, ms));
|
||||
}
|
||||
|
||||
function getUpstreamStatus(error: unknown): number | undefined {
|
||||
if (typeof error !== 'object' || error === null) return undefined;
|
||||
const rec = error as Record<string, unknown>;
|
||||
if (typeof rec.status === 'number') return rec.status;
|
||||
if (typeof rec.statusCode === 'number') return rec.statusCode;
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function resolveTTSRequest(input: ServerTTSRequest): ResolvedServerTTSRequest {
|
||||
const provider = input.provider || 'openai';
|
||||
const rawModel = provider === 'deepinfra' && !input.model ? 'hexgrad/Kokoro-82M' : input.model;
|
||||
const model = (rawModel ?? 'gpt-4o-mini-tts') as SpeechCreateParams['model'];
|
||||
|
||||
const normalizedVoice = (
|
||||
!isKokoroModel(model as string) && input.voice.includes('+')
|
||||
? input.voice.split('+')[0].trim()
|
||||
: input.voice
|
||||
) as string;
|
||||
|
||||
const format = input.format || 'mp3';
|
||||
const speed = Number.isFinite(Number(input.speed)) ? Number(input.speed) : 1;
|
||||
const instructions = (model as string) === 'gpt-4o-mini-tts' && input.instructions
|
||||
? input.instructions
|
||||
: undefined;
|
||||
|
||||
return {
|
||||
text: input.text,
|
||||
voice: normalizedVoice,
|
||||
speed,
|
||||
format,
|
||||
model,
|
||||
instructions,
|
||||
provider,
|
||||
apiKey: input.apiKey,
|
||||
baseUrl: input.baseUrl,
|
||||
testNamespace: input.testNamespace || null,
|
||||
};
|
||||
}
|
||||
|
||||
function makeCacheKey(input: {
|
||||
provider: string;
|
||||
model: string | null | undefined;
|
||||
voice: string | undefined;
|
||||
speed: number;
|
||||
format: string;
|
||||
text: string;
|
||||
instructions?: string;
|
||||
testNamespace?: string | null;
|
||||
}) {
|
||||
const canonical = {
|
||||
provider: input.provider,
|
||||
model: input.model || '',
|
||||
voice: input.voice || '',
|
||||
speed: input.speed,
|
||||
format: input.format,
|
||||
text: input.text,
|
||||
instructions: input.instructions || undefined,
|
||||
testNamespace: input.testNamespace || undefined,
|
||||
};
|
||||
return createHash('sha256').update(JSON.stringify(canonical)).digest('hex');
|
||||
}
|
||||
|
||||
export function buildTTSCacheKey(request: ServerTTSRequest): string {
|
||||
const resolved = resolveTTSRequest(request);
|
||||
return makeCacheKey({
|
||||
provider: resolved.provider,
|
||||
model: resolved.model,
|
||||
voice: resolved.voice,
|
||||
speed: resolved.speed,
|
||||
format: resolved.format,
|
||||
text: resolved.text,
|
||||
instructions: resolved.instructions,
|
||||
testNamespace: resolved.testNamespace,
|
||||
});
|
||||
}
|
||||
|
||||
export function getCachedTTSBuffer(cacheKey: string): Buffer | undefined {
|
||||
return ttsAudioCache.get(cacheKey);
|
||||
}
|
||||
|
||||
export function getTTSContentType(format: string | undefined): string {
|
||||
return (format || 'mp3') === 'mp3' ? 'audio/mpeg' : 'application/octet-stream';
|
||||
}
|
||||
|
||||
async function getTestMockTtsBuffer(testNamespace?: string | null): Promise<Buffer | null> {
|
||||
if (!testNamespace) return null;
|
||||
if (!testMockTtsBufferPromise) {
|
||||
testMockTtsBufferPromise = (async () => {
|
||||
try {
|
||||
await access(TEST_TTS_MOCK_PATH);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
return readFile(TEST_TTS_MOCK_PATH);
|
||||
})();
|
||||
}
|
||||
return testMockTtsBufferPromise;
|
||||
}
|
||||
|
||||
async function fetchTTSBufferWithRetry(
|
||||
openai: OpenAI,
|
||||
createParams: ExtendedSpeechParams,
|
||||
signal: AbortSignal
|
||||
): Promise<Buffer> {
|
||||
let attempt = 0;
|
||||
const maxRetries = Number(process.env.TTS_MAX_RETRIES ?? 2);
|
||||
let delay = Number(process.env.TTS_RETRY_INITIAL_MS ?? 250);
|
||||
const maxDelay = Number(process.env.TTS_RETRY_MAX_MS ?? 2000);
|
||||
const backoff = Number(process.env.TTS_RETRY_BACKOFF ?? 2);
|
||||
|
||||
for (; ;) {
|
||||
try {
|
||||
const response = await openai.audio.speech.create(createParams as SpeechCreateParams, { signal });
|
||||
const buffer = await response.arrayBuffer();
|
||||
return Buffer.from(buffer);
|
||||
} catch (error) {
|
||||
if (signal.aborted || (error instanceof Error && error.name === 'AbortError')) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
const status = getUpstreamStatus(error) ?? 0;
|
||||
const retryable = status === 429 || status >= 500;
|
||||
if (!retryable || attempt >= maxRetries) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
await sleep(Math.min(delay, maxDelay));
|
||||
delay = Math.min(maxDelay, delay * backoff);
|
||||
attempt += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function runProviderRequest(request: ResolvedServerTTSRequest, signal: AbortSignal): Promise<Buffer> {
|
||||
const mockBuffer = await getTestMockTtsBuffer(request.testNamespace);
|
||||
if (mockBuffer) return mockBuffer;
|
||||
|
||||
const openai = new OpenAI({
|
||||
apiKey: request.apiKey,
|
||||
baseURL: request.baseUrl,
|
||||
maxRetries: 0,
|
||||
timeout: Number(process.env.TTS_UPSTREAM_TIMEOUT_MS ?? 45_000),
|
||||
});
|
||||
|
||||
const createParams: ExtendedSpeechParams = {
|
||||
model: request.model,
|
||||
voice: request.voice as SpeechCreateParams['voice'],
|
||||
input: request.text,
|
||||
speed: request.speed,
|
||||
response_format: request.format as SpeechCreateParams['response_format'],
|
||||
};
|
||||
|
||||
if (request.instructions) {
|
||||
createParams.instructions = request.instructions;
|
||||
}
|
||||
|
||||
return fetchTTSBufferWithRetry(openai, createParams, signal);
|
||||
}
|
||||
|
||||
export async function generateTTSBuffer(
|
||||
request: ServerTTSRequest,
|
||||
signal?: AbortSignal
|
||||
): Promise<Buffer> {
|
||||
const resolved = resolveTTSRequest(request);
|
||||
const cacheKey = makeCacheKey({
|
||||
provider: resolved.provider,
|
||||
model: resolved.model,
|
||||
voice: resolved.voice,
|
||||
speed: resolved.speed,
|
||||
format: resolved.format,
|
||||
text: resolved.text,
|
||||
instructions: resolved.instructions,
|
||||
testNamespace: resolved.testNamespace,
|
||||
});
|
||||
|
||||
const cachedBuffer = ttsAudioCache.get(cacheKey);
|
||||
if (cachedBuffer) return cachedBuffer;
|
||||
|
||||
const existing = inflightRequests.get(cacheKey);
|
||||
if (existing) {
|
||||
existing.consumers += 1;
|
||||
|
||||
const onAbort = () => {
|
||||
existing.consumers = Math.max(0, existing.consumers - 1);
|
||||
if (existing.consumers === 0) {
|
||||
existing.controller.abort();
|
||||
}
|
||||
};
|
||||
|
||||
signal?.addEventListener('abort', onAbort, { once: true });
|
||||
try {
|
||||
return await existing.promise;
|
||||
} finally {
|
||||
try {
|
||||
signal?.removeEventListener('abort', onAbort);
|
||||
} catch { }
|
||||
}
|
||||
}
|
||||
|
||||
const controller = new AbortController();
|
||||
const entry: InflightEntry = {
|
||||
controller,
|
||||
consumers: 1,
|
||||
promise: (async () => {
|
||||
try {
|
||||
const buffer = await runProviderRequest(resolved, controller.signal);
|
||||
ttsAudioCache.set(cacheKey, buffer);
|
||||
return buffer;
|
||||
} finally {
|
||||
inflightRequests.delete(cacheKey);
|
||||
}
|
||||
})(),
|
||||
};
|
||||
|
||||
inflightRequests.set(cacheKey, entry);
|
||||
|
||||
const onAbort = () => {
|
||||
entry.consumers = Math.max(0, entry.consumers - 1);
|
||||
if (entry.consumers === 0) {
|
||||
entry.controller.abort();
|
||||
}
|
||||
};
|
||||
|
||||
signal?.addEventListener('abort', onAbort, { once: true });
|
||||
try {
|
||||
return await entry.promise;
|
||||
} finally {
|
||||
try {
|
||||
signal?.removeEventListener('abort', onAbort);
|
||||
} catch { }
|
||||
}
|
||||
}
|
||||
|
|
@ -56,11 +56,12 @@ export interface AudiobookGenerationSettings {
|
|||
nativeSpeed: number;
|
||||
postSpeed: number;
|
||||
format: TTSAudiobookFormat;
|
||||
ttsInstructions?: string;
|
||||
}
|
||||
|
||||
export interface CreateChapterPayload {
|
||||
chapterTitle: string;
|
||||
buffer: TTSAudioBytes; // Array.from(new Uint8Array(audioBuffer))
|
||||
text: string;
|
||||
bookId: string;
|
||||
format: TTSAudiobookFormat;
|
||||
chapterIndex: number;
|
||||
|
|
|
|||
Loading…
Reference in a new issue