From c333ec778a4037f126014415c5b3941dfd5e04d0 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 11 Jan 2026 00:05:41 +0000 Subject: [PATCH] feat(audio): aggressive preloading and job queue foundation Improve audio caching with more aggressive preloading: - Increase cache size from 25 to 50 sentences for better buffering - Remove setTimeout delays - start all preloads immediately - Increase preload ahead count from 3 to 5 sentences - Add 500ms recovery delay when skipping problematic sentences - Better error logging with sentence previews Add foundation for background audiobook generation: - Create Job type definitions for audiobook generation tasks - Add jobs table to IndexedDB (database version 6) - Implement job CRUD operations in Dexie - Track job status, progress, and results - Support for querying jobs by status This prepares for server-side background audiobook processing that doesn't require keeping the browser open. Changes: - TTSContext: Increase cache to 50, remove preload delays, add error recovery - types/jobs.ts: New file with job type definitions - lib/dexie.ts: Add jobs table and helper functions --- src/contexts/TTSContext.tsx | 39 +++++++++++++----------- src/lib/dexie.ts | 60 ++++++++++++++++++++++++++++++++++++- src/types/jobs.ts | 46 ++++++++++++++++++++++++++++ 3 files changed, 126 insertions(+), 19 deletions(-) create mode 100644 src/types/jobs.ts diff --git a/src/contexts/TTSContext.tsx b/src/contexts/TTSContext.tsx index f32d71d..8ea3e6e 100644 --- a/src/contexts/TTSContext.tsx +++ b/src/contexts/TTSContext.tsx @@ -296,7 +296,7 @@ export function TTSProvider({ children }: { children: ReactNode }): ReactElement // Audio and voice management hooks const audioContext = useAudioContext(); - const audioCache = useAudioCache(25); + const audioCache = useAudioCache(50); // Increased from 25 to 50 for better buffering const { availableVoices, fetchVoices } = useVoiceManagement(openApiKey, openApiBaseUrl, configTTSProvider, configTTSModel); // Add ref for location change handler @@ -1068,7 +1068,10 @@ export function TTSProvider({ children }: { children: ReactNode }): ReactElement // User won't be interrupted by error toasts during reading console.warn('Skipping problematic sentence, continuing playback...'); - advance(); + // Add a small delay before advancing to give the server a brief rest + setTimeout(() => { + advance(); + }, 500); // 500ms delay before trying next sentence } }, onend: function (this: Howl) { @@ -1123,7 +1126,10 @@ export function TTSProvider({ children }: { children: ReactNode }): ReactElement // Just log to console and move to next sentence console.warn('Skipping problematic sentence, continuing playback...'); - advance(); + // Add a small delay before advancing to give the server a brief rest + setTimeout(() => { + advance(); + }, 500); // 500ms delay before trying next sentence return null; } }, [isPlaying, advance, activeHowl, processSentence, audioSpeed]); @@ -1201,15 +1207,16 @@ export function TTSProvider({ children }: { children: ReactNode }): ReactElement }, [activeHowl, isPlaying, currentSentenceAlignment]); /** - * Preloads the next 3 sentences' audio for smoother playback - * Uses progressive delays to avoid overwhelming the server + * Preloads the next 5 sentences' audio for smoother playback + * Starts all preloads immediately for maximum buffering */ const preloadNextAudio = useCallback(async () => { try { - // Preload the next 3 sentences to ensure smooth playback - const PRELOAD_AHEAD_COUNT = 3; - const PRELOAD_DELAY_MS = 50; // Small delay between requests + // Preload the next 5 sentences to ensure smooth playback + const PRELOAD_AHEAD_COUNT = 5; + // Start all preloads immediately (no delays) + // The server-side cache and de-duplication will handle load balancing for (let i = 1; i <= PRELOAD_AHEAD_COUNT; i++) { const nextSentence = sentences[currentIndex + i]; if (!nextSentence) break; // Stop if we've reached the end @@ -1222,17 +1229,13 @@ export function TTSProvider({ children }: { children: ReactNode }): ReactElement ttsModel, ); + // Only start preload if not already cached or in progress if (!audioCache.has(nextKey) && !preloadRequests.current.has(nextSentence)) { - // Add progressive delay to avoid overwhelming the server - // First request: 50ms, second: 100ms, third: 150ms - const delay = PRELOAD_DELAY_MS * i; - - setTimeout(() => { - // Start preloading but don't wait for it to complete - processSentence(nextSentence, true).catch(error => { - console.warn(`Error preloading sentence ${i} ahead:`, error); - }); - }, delay); + // Start preloading immediately (no setTimeout delay) + // Fire and forget - errors are logged but don't block playback + processSentence(nextSentence, true).catch(error => { + console.warn(`Error preloading sentence ${i} ahead (${nextSentence.substring(0, 30)}...):`, error); + }); } } } catch (error) { diff --git a/src/lib/dexie.ts b/src/lib/dexie.ts index 3fe1d72..b573467 100644 --- a/src/lib/dexie.ts +++ b/src/lib/dexie.ts @@ -1,10 +1,11 @@ import Dexie, { type EntityTable } from 'dexie'; import { APP_CONFIG_DEFAULTS, type ViewType, type SavedVoices, type AppConfigRow } from '@/types/config'; import { PDFDocument, EPUBDocument, HTMLDocument, DocumentListState, SyncedDocument } from '@/types/documents'; +import { Job } from '@/types/jobs'; const DB_NAME = 'openreader-db'; // Managed via Dexie (version bumped from the original manual IndexedDB) -const DB_VERSION = 5; +const DB_VERSION = 6; // Bumped to 6 for jobs table const PDF_TABLE = 'pdf-documents' as const; const EPUB_TABLE = 'epub-documents' as const; @@ -12,6 +13,7 @@ const HTML_TABLE = 'html-documents' as const; const CONFIG_TABLE = 'config' as const; const APP_CONFIG_TABLE = 'app-config' as const; const LAST_LOCATION_TABLE = 'last-locations' as const; +const JOBS_TABLE = 'jobs' as const; export interface LastLocationRow { docId: string; @@ -30,6 +32,7 @@ type OpenReaderDB = Dexie & { [CONFIG_TABLE]: EntityTable; [APP_CONFIG_TABLE]: EntityTable; [LAST_LOCATION_TABLE]: EntityTable; + [JOBS_TABLE]: EntityTable; }; export const db = new Dexie(DB_NAME) as OpenReaderDB; @@ -154,12 +157,14 @@ function buildAppConfigFromRaw(raw: RawConfigMap): AppConfigRow { // Version 5: introduce app-config and last-locations tables, migrate scattered config keys, // and drop the legacy config table in a single upgrade step. +// Version 6: add jobs table for background audiobook generation db.version(DB_VERSION).stores({ [PDF_TABLE]: 'id, type, name, lastModified, size, folderId', [EPUB_TABLE]: 'id, type, name, lastModified, size, folderId', [HTML_TABLE]: 'id, type, name, lastModified, size, folderId', [APP_CONFIG_TABLE]: 'id', [LAST_LOCATION_TABLE]: 'docId', + [JOBS_TABLE]: 'id, status, type, createdAt', // `null` here means: drop the old 'config' table after upgrade runs, // but Dexie still lets us read it inside the upgrade transaction. [CONFIG_TABLE]: null, @@ -555,3 +560,56 @@ export async function loadDocumentsFromServer( return { lastSync: Date.now() }; } + +// Job management helpers + +export async function createJob(job: Job): Promise { + await withDB(async () => { + console.log('Creating job via Dexie:', job.id); + await db[JOBS_TABLE].put(job); + }); +} + +export async function getJob(id: string): Promise { + return withDB(async () => { + console.log('Fetching job via Dexie:', id); + return db[JOBS_TABLE].get(id); + }); +} + +export async function getAllJobs(): Promise { + return withDB(async () => { + console.log('Fetching all jobs via Dexie'); + return db[JOBS_TABLE].orderBy('createdAt').reverse().toArray(); + }); +} + +export async function getJobsByStatus(status: string): Promise { + return withDB(async () => { + console.log('Fetching jobs by status via Dexie:', status); + return db[JOBS_TABLE].where('status').equals(status).toArray(); + }); +} + +export async function updateJob(id: string, updates: Partial): Promise { + await withDB(async () => { + console.log('Updating job via Dexie:', id); + await db[JOBS_TABLE].update(id, updates); + }); +} + +export async function deleteJob(id: string): Promise { + await withDB(async () => { + console.log('Deleting job via Dexie:', id); + await db[JOBS_TABLE].delete(id); + }); +} + +export async function clearCompletedJobs(): Promise { + await withDB(async () => { + console.log('Clearing completed jobs via Dexie'); + const completedJobs = await db[JOBS_TABLE].where('status').anyOf('completed', 'failed', 'cancelled').toArray(); + const ids = completedJobs.map(job => job.id); + await db[JOBS_TABLE].bulkDelete(ids); + }); +} diff --git a/src/types/jobs.ts b/src/types/jobs.ts new file mode 100644 index 0000000..a74c485 --- /dev/null +++ b/src/types/jobs.ts @@ -0,0 +1,46 @@ +/** + * Background job types for audiobook generation + */ + +export type JobStatus = 'pending' | 'processing' | 'completed' | 'failed' | 'cancelled'; + +export type JobType = 'audiobook-generation'; + +export interface AudiobookJobData { + documentId: string; + documentName: string; + voice: string; + speed: number; + audioPlayerSpeed: number; + ttsProvider: string; + ttsModel: string; + ttsInstructions?: string; + format: 'mp3' | 'm4b'; +} + +export interface Job { + id: string; + type: JobType; + status: JobStatus; + data: AudiobookJobData; + progress: number; // 0-100 + currentStep?: string; // Human-readable status + error?: string; + result?: { + bookId: string; + format: string; + totalDuration: number; + chapterCount: number; + }; + createdAt: number; + startedAt?: number; + completedAt?: number; +} + +export interface JobProgress { + jobId: string; + progress: number; + currentStep: string; + sentencesProcessed?: number; + totalSentences?: number; +}