openreader/src/utils/audio.ts

132 lines
No EOL
3.6 KiB
TypeScript

/**
* Utility functions for audio processing
*/
interface AudioChunk {
buffer: ArrayBuffer;
title?: string;
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
* @param audioChunks Array of audio chunks with metadata
* @param format Output format ('mp3' or 'm4b')
* @param setIsAudioCombining Optional callback to track combining state
* @returns Promise resolving to the combined audio buffer
*/
export const combineAudioChunks = async (
audioChunks: AudioChunk[],
format: 'mp3' | 'm4b',
setIsAudioCombining?: (state: boolean) => void
): Promise<ArrayBuffer> => {
if (setIsAudioCombining) {
setIsAudioCombining(true);
}
try {
if (format === 'm4b') {
// Filter out chunks without titles and silence buffers
const titledChunks = audioChunks.filter(chunk => chunk.title && chunk.buffer.byteLength > 48000);
let bookId: string | undefined;
// Upload each chunk sequentially and get book ID
for (const chunk of titledChunks) {
const response = await fetch('/api/audio/convert', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
chapterTitle: chunk.title,
buffer: Array.from(new Uint8Array(chunk.buffer)),
bookId // Will be undefined for first chunk, then set for subsequent ones
}),
});
if (!response.ok) {
throw new Error('Failed to upload audio chunk');
}
const result = await response.json();
bookId = result.bookId; // Save book ID for subsequent chunks
}
if (!bookId) {
throw new Error('No book ID received from server');
}
// Get the final combined M4B file
const m4bResponse = await fetch(`/api/audio/convert?bookId=${bookId}`);
if (!m4bResponse.ok) {
throw new Error('Failed to get combined M4B');
}
return await m4bResponse.arrayBuffer();
}
// For MP3, just concatenate the buffers
const totalLength = audioChunks.reduce((acc, chunk) => acc + chunk.buffer.byteLength, 0);
const combinedBuffer = new Uint8Array(totalLength);
let offset = 0;
for (const chunk of audioChunks) {
combinedBuffer.set(new Uint8Array(chunk.buffer), offset);
offset += chunk.buffer.byteLength;
}
return combinedBuffer.buffer;
} finally {
if (setIsAudioCombining) setIsAudioCombining(false);
}
}