implement retry logic for TTS API calls and clean up code

This commit is contained in:
Richard Roberson 2025-03-03 23:03:52 -07:00
parent 2f2442e5a2
commit ce9df9a5ab
7 changed files with 151 additions and 72 deletions

View file

@ -37,7 +37,7 @@ export default defineConfig({
/* Run your local dev server before starting the tests */ /* Run your local dev server before starting the tests */
webServer: { 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', url: 'http://localhost:3003',
reuseExistingServer: !process.env.CI, reuseExistingServer: !process.env.CI,
timeout: 120 * 1000, timeout: 120 * 1000,

View file

@ -9,7 +9,7 @@ export async function GET(req: NextRequest) {
const openApiBaseUrl = req.headers.get('x-openai-base-url') || process.env.API_BASE; const openApiBaseUrl = req.headers.get('x-openai-base-url') || process.env.API_BASE;
// Request voices from OpenAI // Request voices from OpenAI
const response = await fetch(`${openApiBaseUrl || 'https://api.openai.com/v1'}/audio/voices`, { const response = await fetch(`${openApiBaseUrl}/audio/voices`, {
headers: { headers: {
'Authorization': `Bearer ${openApiKey}`, 'Authorization': `Bearer ${openApiKey}`,
'Content-Type': 'application/json', 'Content-Type': 'application/json',

View file

@ -50,7 +50,7 @@ const isDev = process.env.NEXT_PUBLIC_NODE_ENV !== 'production' || process.env.N
export default function RootLayout({ children }: { children: ReactNode }) { export default function RootLayout({ children }: { children: ReactNode }) {
return ( return (
<html lang="en" suppressHydrationWarning> <html lang="en">
<head> <head>
<meta name="color-scheme" content="light dark" /> <meta name="color-scheme" content="light dark" />
</head> </head>

View file

@ -20,6 +20,7 @@ import { SpineItem } from 'epubjs/types/section';
import { useParams } from 'next/navigation'; import { useParams } from 'next/navigation';
import { useConfig } from './ConfigContext'; import { useConfig } from './ConfigContext';
import { combineAudioChunks } from '@/utils/audio'; import { combineAudioChunks } from '@/utils/audio';
import { withRetry } from '@/utils/audio';
interface EPUBContextType { interface EPUBContextType {
currDocData: ArrayBuffer | undefined; currDocData: ArrayBuffer | undefined;
@ -202,7 +203,7 @@ export function EPUBProvider({ children }: { children: ReactNode }) {
// Get TOC for chapter titles // Get TOC for chapter titles
const chapters = tocRef.current || []; const chapters = tocRef.current || [];
console.log('Chapter map:', chapters); console.log('Chapters:', chapters);
// Create a map of section hrefs to their chapter titles // Create a map of section hrefs to their chapter titles
const sectionTitleMap = new Map<string, string>(); const sectionTitleMap = new Map<string, string>();
@ -210,7 +211,6 @@ export function EPUBProvider({ children }: { children: ReactNode }) {
// First, loop through all chapters to create the mapping // First, loop through all chapters to create the mapping
for (const chapter of chapters) { for (const chapter of chapters) {
if (!chapter.href) continue; if (!chapter.href) continue;
const chapterBaseHref = chapter.href.split('#')[0]; const chapterBaseHref = chapter.href.split('#')[0];
const chapterTitle = chapter.label.trim(); const chapterTitle = chapter.label.trim();
@ -240,6 +240,8 @@ export function EPUBProvider({ children }: { children: ReactNode }) {
if (!trimmedText) continue; if (!trimmedText) continue;
try { try {
const audioBuffer = await withRetry(
async () => {
const ttsResponse = await fetch('/api/tts', { const ttsResponse = await fetch('/api/tts', {
method: 'POST', method: 'POST',
headers: { headers: {
@ -259,10 +261,19 @@ export function EPUBProvider({ children }: { children: ReactNode }) {
throw new Error(`TTS processing failed with status ${ttsResponse.status}`); throw new Error(`TTS processing failed with status ${ttsResponse.status}`);
} }
const audioBuffer = await ttsResponse.arrayBuffer(); const buffer = await ttsResponse.arrayBuffer();
if (audioBuffer.byteLength === 0) { if (buffer.byteLength === 0) {
throw new Error('Received empty audio buffer from TTS'); throw new Error('Received empty audio buffer from TTS');
} }
return buffer;
},
{
maxRetries: 2,
initialDelay: 5000,
maxDelay: 10000,
backoffFactor: 2
}
);
// Get the chapter title from our pre-computed map // Get the chapter title from our pre-computed map
let chapterTitle = sectionTitleMap.get(section.href); let chapterTitle = sectionTitleMap.get(section.href);

View file

@ -35,7 +35,7 @@ import {
} from '@/utils/pdf'; } from '@/utils/pdf';
import type { PDFDocumentProxy } from 'pdfjs-dist'; 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 * Interface defining all available methods and properties in the PDF context
@ -235,6 +235,8 @@ export function PDFProvider({ children }: { children: ReactNode }) {
const text = textPerPage[i]; const text = textPerPage[i];
try { try {
const audioBuffer = await withRetry(
async () => {
const ttsResponse = await fetch('/api/tts', { const ttsResponse = await fetch('/api/tts', {
method: 'POST', method: 'POST',
headers: { headers: {
@ -254,10 +256,19 @@ export function PDFProvider({ children }: { children: ReactNode }) {
throw new Error(`TTS processing failed with status ${ttsResponse.status}`); throw new Error(`TTS processing failed with status ${ttsResponse.status}`);
} }
const audioBuffer = await ttsResponse.arrayBuffer(); const buffer = await ttsResponse.arrayBuffer();
if (audioBuffer.byteLength === 0) { if (buffer.byteLength === 0) {
throw new Error('Received empty audio buffer from TTS'); throw new Error('Received empty audio buffer from TTS');
} }
return buffer;
},
{
maxRetries: 3,
initialDelay: 1000,
maxDelay: 5000,
backoffFactor: 2
}
);
audioChunks.push({ audioChunks.push({
buffer: audioBuffer, buffer: audioBuffer,
@ -273,8 +284,6 @@ export function PDFProvider({ children }: { children: ReactNode }) {
}); });
currentTime += (audioBuffer.byteLength + 48000) / 48000; currentTime += (audioBuffer.byteLength + 48000) / 48000;
// Update progress based on processed text length
processedLength += text.length; processedLength += text.length;
onProgress((processedLength / totalLength) * 100); onProgress((processedLength / totalLength) * 100);

View file

@ -35,6 +35,7 @@ import { useMediaSession } from '@/hooks/audio/useMediaSession';
import { useAudioContext } from '@/hooks/audio/useAudioContext'; import { useAudioContext } from '@/hooks/audio/useAudioContext';
import { getLastDocumentLocation } from '@/utils/indexedDB'; import { getLastDocumentLocation } from '@/utils/indexedDB';
import { useBackgroundState } from '@/hooks/audio/useBackgroundState'; import { useBackgroundState } from '@/hooks/audio/useBackgroundState';
import { withRetry } from '@/utils/audio';
// Media globals // Media globals
declare global { declare global {
@ -409,6 +410,8 @@ export function TTSProvider({ children }: { children: ReactNode }) {
const controller = new AbortController(); const controller = new AbortController();
activeAbortControllers.current.add(controller); activeAbortControllers.current.add(controller);
const arrayBuffer = await withRetry(
async () => {
const response = await fetch('/api/tts', { const response = await fetch('/api/tts', {
method: 'POST', method: 'POST',
headers: { headers: {
@ -424,15 +427,22 @@ export function TTSProvider({ children }: { children: ReactNode }) {
signal: controller.signal, signal: controller.signal,
}); });
// Remove the controller once the request is complete
activeAbortControllers.current.delete(controller);
if (!response.ok) { if (!response.ok) {
throw new Error('Failed to generate audio'); throw new Error('Failed to generate audio');
} }
// Get the raw array buffer - no need to decode since it's already MP3 return response.arrayBuffer();
const arrayBuffer = await response.arrayBuffer(); },
{
maxRetries: 3,
initialDelay: 1000,
maxDelay: 5000,
backoffFactor: 2
}
);
// Remove the controller once the request is complete
activeAbortControllers.current.delete(controller);
// Cache the array buffer // Cache the array buffer
audioCache.set(sentence, arrayBuffer); audioCache.set(sentence, arrayBuffer);

View file

@ -8,6 +8,55 @@ interface AudioChunk {
startTime: number; 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 <T>(
operation: () => Promise<T>,
options: RetryOptions = {}
): Promise<T> => {
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 * Combines audio chunks into a single audio file
* @param audioChunks Array of audio chunks with metadata * @param audioChunks Array of audio chunks with metadata