diff --git a/playwright.config.ts b/playwright.config.ts
index 3529846..e654a15 100644
--- a/playwright.config.ts
+++ b/playwright.config.ts
@@ -37,7 +37,7 @@ export default defineConfig({
/* Run your local dev server before starting the tests */
webServer: {
- command: process.env.CI ? 'npm run build && npm run start' : 'npm run dev',
+ command: 'npm run build && npm run start',
url: 'http://localhost:3003',
reuseExistingServer: !process.env.CI,
timeout: 120 * 1000,
diff --git a/src/app/api/tts/voices/route.ts b/src/app/api/tts/voices/route.ts
index a67b3cd..75947cd 100644
--- a/src/app/api/tts/voices/route.ts
+++ b/src/app/api/tts/voices/route.ts
@@ -9,7 +9,7 @@ export async function GET(req: NextRequest) {
const openApiBaseUrl = req.headers.get('x-openai-base-url') || process.env.API_BASE;
// Request voices from OpenAI
- const response = await fetch(`${openApiBaseUrl || 'https://api.openai.com/v1'}/audio/voices`, {
+ const response = await fetch(`${openApiBaseUrl}/audio/voices`, {
headers: {
'Authorization': `Bearer ${openApiKey}`,
'Content-Type': 'application/json',
diff --git a/src/app/layout.tsx b/src/app/layout.tsx
index 0406bb8..116bb5e 100644
--- a/src/app/layout.tsx
+++ b/src/app/layout.tsx
@@ -50,7 +50,7 @@ const isDev = process.env.NEXT_PUBLIC_NODE_ENV !== 'production' || process.env.N
export default function RootLayout({ children }: { children: ReactNode }) {
return (
-
+
diff --git a/src/contexts/EPUBContext.tsx b/src/contexts/EPUBContext.tsx
index 9c3dbdd..d88113e 100644
--- a/src/contexts/EPUBContext.tsx
+++ b/src/contexts/EPUBContext.tsx
@@ -20,6 +20,7 @@ import { SpineItem } from 'epubjs/types/section';
import { useParams } from 'next/navigation';
import { useConfig } from './ConfigContext';
import { combineAudioChunks } from '@/utils/audio';
+import { withRetry } from '@/utils/audio';
interface EPUBContextType {
currDocData: ArrayBuffer | undefined;
@@ -202,7 +203,7 @@ export function EPUBProvider({ children }: { children: ReactNode }) {
// Get TOC for chapter titles
const chapters = tocRef.current || [];
- console.log('Chapter map:', chapters);
+ console.log('Chapters:', chapters);
// Create a map of section hrefs to their chapter titles
const sectionTitleMap = new Map();
@@ -210,7 +211,6 @@ export function EPUBProvider({ children }: { children: ReactNode }) {
// First, loop through all chapters to create the mapping
for (const chapter of chapters) {
if (!chapter.href) continue;
-
const chapterBaseHref = chapter.href.split('#')[0];
const chapterTitle = chapter.label.trim();
@@ -240,29 +240,40 @@ export function EPUBProvider({ children }: { children: ReactNode }) {
if (!trimmedText) continue;
try {
- const ttsResponse = await fetch('/api/tts', {
- method: 'POST',
- headers: {
- 'x-openai-key': apiKey,
- 'x-openai-base-url': baseUrl,
+ const audioBuffer = await withRetry(
+ async () => {
+ const ttsResponse = await fetch('/api/tts', {
+ method: 'POST',
+ headers: {
+ 'x-openai-key': apiKey,
+ 'x-openai-base-url': baseUrl,
+ },
+ body: JSON.stringify({
+ text: trimmedText,
+ voice: voice,
+ speed: voiceSpeed,
+ format: format === 'm4b' ? 'aac' : 'mp3',
+ }),
+ signal
+ });
+
+ if (!ttsResponse.ok) {
+ throw new Error(`TTS processing failed with status ${ttsResponse.status}`);
+ }
+
+ const buffer = await ttsResponse.arrayBuffer();
+ if (buffer.byteLength === 0) {
+ throw new Error('Received empty audio buffer from TTS');
+ }
+ return buffer;
},
- body: JSON.stringify({
- text: trimmedText,
- voice: voice,
- speed: voiceSpeed,
- format: format === 'm4b' ? 'aac' : 'mp3',
- }),
- signal
- });
-
- if (!ttsResponse.ok) {
- throw new Error(`TTS processing failed with status ${ttsResponse.status}`);
- }
-
- const audioBuffer = await ttsResponse.arrayBuffer();
- if (audioBuffer.byteLength === 0) {
- throw new Error('Received empty audio buffer from TTS');
- }
+ {
+ maxRetries: 2,
+ initialDelay: 5000,
+ maxDelay: 10000,
+ backoffFactor: 2
+ }
+ );
// Get the chapter title from our pre-computed map
let chapterTitle = sectionTitleMap.get(section.href);
diff --git a/src/contexts/PDFContext.tsx b/src/contexts/PDFContext.tsx
index 9d7f910..96cad55 100644
--- a/src/contexts/PDFContext.tsx
+++ b/src/contexts/PDFContext.tsx
@@ -35,7 +35,7 @@ import {
} from '@/utils/pdf';
import type { PDFDocumentProxy } from 'pdfjs-dist';
-import { combineAudioChunks } from '@/utils/audio';
+import { combineAudioChunks, withRetry } from '@/utils/audio';
/**
* Interface defining all available methods and properties in the PDF context
@@ -235,29 +235,40 @@ export function PDFProvider({ children }: { children: ReactNode }) {
const text = textPerPage[i];
try {
- const ttsResponse = await fetch('/api/tts', {
- method: 'POST',
- headers: {
- 'x-openai-key': apiKey,
- 'x-openai-base-url': baseUrl,
+ const audioBuffer = await withRetry(
+ async () => {
+ const ttsResponse = await fetch('/api/tts', {
+ method: 'POST',
+ headers: {
+ 'x-openai-key': apiKey,
+ 'x-openai-base-url': baseUrl,
+ },
+ body: JSON.stringify({
+ text,
+ voice: voice,
+ speed: voiceSpeed,
+ format: format === 'm4b' ? 'aac' : 'mp3'
+ }),
+ signal
+ });
+
+ if (!ttsResponse.ok) {
+ throw new Error(`TTS processing failed with status ${ttsResponse.status}`);
+ }
+
+ const buffer = await ttsResponse.arrayBuffer();
+ if (buffer.byteLength === 0) {
+ throw new Error('Received empty audio buffer from TTS');
+ }
+ return buffer;
},
- body: JSON.stringify({
- text,
- voice: voice,
- speed: voiceSpeed,
- format: format === 'm4b' ? 'aac' : 'mp3'
- }),
- signal
- });
-
- if (!ttsResponse.ok) {
- throw new Error(`TTS processing failed with status ${ttsResponse.status}`);
- }
-
- const audioBuffer = await ttsResponse.arrayBuffer();
- if (audioBuffer.byteLength === 0) {
- throw new Error('Received empty audio buffer from TTS');
- }
+ {
+ maxRetries: 3,
+ initialDelay: 1000,
+ maxDelay: 5000,
+ backoffFactor: 2
+ }
+ );
audioChunks.push({
buffer: audioBuffer,
@@ -273,8 +284,6 @@ export function PDFProvider({ children }: { children: ReactNode }) {
});
currentTime += (audioBuffer.byteLength + 48000) / 48000;
-
- // Update progress based on processed text length
processedLength += text.length;
onProgress((processedLength / totalLength) * 100);
diff --git a/src/contexts/TTSContext.tsx b/src/contexts/TTSContext.tsx
index d85414b..35cb2bb 100644
--- a/src/contexts/TTSContext.tsx
+++ b/src/contexts/TTSContext.tsx
@@ -35,6 +35,7 @@ import { useMediaSession } from '@/hooks/audio/useMediaSession';
import { useAudioContext } from '@/hooks/audio/useAudioContext';
import { getLastDocumentLocation } from '@/utils/indexedDB';
import { useBackgroundState } from '@/hooks/audio/useBackgroundState';
+import { withRetry } from '@/utils/audio';
// Media globals
declare global {
@@ -409,31 +410,40 @@ export function TTSProvider({ children }: { children: ReactNode }) {
const controller = new AbortController();
activeAbortControllers.current.add(controller);
- const response = await fetch('/api/tts', {
- method: 'POST',
- headers: {
- 'Content-Type': 'application/json',
- 'x-openai-key': openApiKey || '',
- 'x-openai-base-url': openApiBaseUrl || '',
+ const arrayBuffer = await withRetry(
+ async () => {
+ const response = await fetch('/api/tts', {
+ method: 'POST',
+ headers: {
+ 'Content-Type': 'application/json',
+ 'x-openai-key': openApiKey || '',
+ 'x-openai-base-url': openApiBaseUrl || '',
+ },
+ body: JSON.stringify({
+ text: sentence,
+ voice: voice,
+ speed: speed,
+ }),
+ signal: controller.signal,
+ });
+
+ if (!response.ok) {
+ throw new Error('Failed to generate audio');
+ }
+
+ return response.arrayBuffer();
},
- body: JSON.stringify({
- text: sentence,
- voice: voice,
- speed: speed,
- }),
- signal: controller.signal,
- });
+ {
+ maxRetries: 3,
+ initialDelay: 1000,
+ maxDelay: 5000,
+ backoffFactor: 2
+ }
+ );
// Remove the controller once the request is complete
activeAbortControllers.current.delete(controller);
- if (!response.ok) {
- throw new Error('Failed to generate audio');
- }
-
- // Get the raw array buffer - no need to decode since it's already MP3
- const arrayBuffer = await response.arrayBuffer();
-
// Cache the array buffer
audioCache.set(sentence, arrayBuffer);
diff --git a/src/utils/audio.ts b/src/utils/audio.ts
index be57152..90118d9 100644
--- a/src/utils/audio.ts
+++ b/src/utils/audio.ts
@@ -8,6 +8,55 @@ interface AudioChunk {
startTime: number;
}
+interface RetryOptions {
+ maxRetries?: number;
+ initialDelay?: number;
+ maxDelay?: number;
+ backoffFactor?: number;
+}
+
+/**
+ * Executes a function with exponential backoff retry logic
+ * @param operation Function to retry
+ * @param options Retry configuration options
+ * @returns Promise resolving to the operation result
+ */
+export const withRetry = async (
+ operation: () => Promise,
+ options: RetryOptions = {}
+): Promise => {
+ const {
+ maxRetries = 3,
+ initialDelay = 1000,
+ maxDelay = 10000,
+ backoffFactor = 2
+ } = options;
+
+ let lastError: Error | null = null;
+
+ for (let attempt = 0; attempt < maxRetries; attempt++) {
+ try {
+ return await operation();
+ } catch (error) {
+ lastError = error instanceof Error ? error : new Error(String(error));
+
+ if (attempt === maxRetries - 1) {
+ break;
+ }
+
+ const delay = Math.min(
+ initialDelay * Math.pow(backoffFactor, attempt),
+ maxDelay
+ );
+
+ console.log(`Retry attempt ${attempt + 1}/${maxRetries} failed. Retrying in ${delay}ms...`);
+ await new Promise(resolve => setTimeout(resolve, delay));
+ }
+ }
+
+ throw lastError || new Error('Operation failed after retries');
+}
+
/**
* Combines audio chunks into a single audio file
* @param audioChunks Array of audio chunks with metadata