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
This commit is contained in:
Claude 2026-01-11 00:05:41 +00:00
parent 70ff7a4f68
commit c333ec778a
No known key found for this signature in database
3 changed files with 126 additions and 19 deletions

View file

@ -296,7 +296,7 @@ export function TTSProvider({ children }: { children: ReactNode }): ReactElement
// Audio and voice management hooks // Audio and voice management hooks
const audioContext = useAudioContext(); 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); const { availableVoices, fetchVoices } = useVoiceManagement(openApiKey, openApiBaseUrl, configTTSProvider, configTTSModel);
// Add ref for location change handler // 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 // User won't be interrupted by error toasts during reading
console.warn('Skipping problematic sentence, continuing playback...'); 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) { onend: function (this: Howl) {
@ -1123,7 +1126,10 @@ export function TTSProvider({ children }: { children: ReactNode }): ReactElement
// Just log to console and move to next sentence // Just log to console and move to next sentence
console.warn('Skipping problematic sentence, continuing playback...'); 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; return null;
} }
}, [isPlaying, advance, activeHowl, processSentence, audioSpeed]); }, [isPlaying, advance, activeHowl, processSentence, audioSpeed]);
@ -1201,15 +1207,16 @@ export function TTSProvider({ children }: { children: ReactNode }): ReactElement
}, [activeHowl, isPlaying, currentSentenceAlignment]); }, [activeHowl, isPlaying, currentSentenceAlignment]);
/** /**
* Preloads the next 3 sentences' audio for smoother playback * Preloads the next 5 sentences' audio for smoother playback
* Uses progressive delays to avoid overwhelming the server * Starts all preloads immediately for maximum buffering
*/ */
const preloadNextAudio = useCallback(async () => { const preloadNextAudio = useCallback(async () => {
try { try {
// Preload the next 3 sentences to ensure smooth playback // Preload the next 5 sentences to ensure smooth playback
const PRELOAD_AHEAD_COUNT = 3; const PRELOAD_AHEAD_COUNT = 5;
const PRELOAD_DELAY_MS = 50; // Small delay between requests
// 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++) { for (let i = 1; i <= PRELOAD_AHEAD_COUNT; i++) {
const nextSentence = sentences[currentIndex + i]; const nextSentence = sentences[currentIndex + i];
if (!nextSentence) break; // Stop if we've reached the end if (!nextSentence) break; // Stop if we've reached the end
@ -1222,17 +1229,13 @@ export function TTSProvider({ children }: { children: ReactNode }): ReactElement
ttsModel, ttsModel,
); );
// Only start preload if not already cached or in progress
if (!audioCache.has(nextKey) && !preloadRequests.current.has(nextSentence)) { if (!audioCache.has(nextKey) && !preloadRequests.current.has(nextSentence)) {
// Add progressive delay to avoid overwhelming the server // Start preloading immediately (no setTimeout delay)
// First request: 50ms, second: 100ms, third: 150ms // Fire and forget - errors are logged but don't block playback
const delay = PRELOAD_DELAY_MS * i; processSentence(nextSentence, true).catch(error => {
console.warn(`Error preloading sentence ${i} ahead (${nextSentence.substring(0, 30)}...):`, error);
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);
} }
} }
} catch (error) { } catch (error) {

View file

@ -1,10 +1,11 @@
import Dexie, { type EntityTable } from 'dexie'; import Dexie, { type EntityTable } from 'dexie';
import { APP_CONFIG_DEFAULTS, type ViewType, type SavedVoices, type AppConfigRow } from '@/types/config'; import { APP_CONFIG_DEFAULTS, type ViewType, type SavedVoices, type AppConfigRow } from '@/types/config';
import { PDFDocument, EPUBDocument, HTMLDocument, DocumentListState, SyncedDocument } from '@/types/documents'; import { PDFDocument, EPUBDocument, HTMLDocument, DocumentListState, SyncedDocument } from '@/types/documents';
import { Job } from '@/types/jobs';
const DB_NAME = 'openreader-db'; const DB_NAME = 'openreader-db';
// Managed via Dexie (version bumped from the original manual IndexedDB) // 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 PDF_TABLE = 'pdf-documents' as const;
const EPUB_TABLE = 'epub-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 CONFIG_TABLE = 'config' as const;
const APP_CONFIG_TABLE = 'app-config' as const; const APP_CONFIG_TABLE = 'app-config' as const;
const LAST_LOCATION_TABLE = 'last-locations' as const; const LAST_LOCATION_TABLE = 'last-locations' as const;
const JOBS_TABLE = 'jobs' as const;
export interface LastLocationRow { export interface LastLocationRow {
docId: string; docId: string;
@ -30,6 +32,7 @@ type OpenReaderDB = Dexie & {
[CONFIG_TABLE]: EntityTable<ConfigRow, 'key'>; [CONFIG_TABLE]: EntityTable<ConfigRow, 'key'>;
[APP_CONFIG_TABLE]: EntityTable<AppConfigRow, 'id'>; [APP_CONFIG_TABLE]: EntityTable<AppConfigRow, 'id'>;
[LAST_LOCATION_TABLE]: EntityTable<LastLocationRow, 'docId'>; [LAST_LOCATION_TABLE]: EntityTable<LastLocationRow, 'docId'>;
[JOBS_TABLE]: EntityTable<Job, 'id'>;
}; };
export const db = new Dexie(DB_NAME) as OpenReaderDB; 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, // Version 5: introduce app-config and last-locations tables, migrate scattered config keys,
// and drop the legacy config table in a single upgrade step. // 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({ db.version(DB_VERSION).stores({
[PDF_TABLE]: 'id, type, name, lastModified, size, folderId', [PDF_TABLE]: 'id, type, name, lastModified, size, folderId',
[EPUB_TABLE]: 'id, type, name, lastModified, size, folderId', [EPUB_TABLE]: 'id, type, name, lastModified, size, folderId',
[HTML_TABLE]: 'id, type, name, lastModified, size, folderId', [HTML_TABLE]: 'id, type, name, lastModified, size, folderId',
[APP_CONFIG_TABLE]: 'id', [APP_CONFIG_TABLE]: 'id',
[LAST_LOCATION_TABLE]: 'docId', [LAST_LOCATION_TABLE]: 'docId',
[JOBS_TABLE]: 'id, status, type, createdAt',
// `null` here means: drop the old 'config' table after upgrade runs, // `null` here means: drop the old 'config' table after upgrade runs,
// but Dexie still lets us read it inside the upgrade transaction. // but Dexie still lets us read it inside the upgrade transaction.
[CONFIG_TABLE]: null, [CONFIG_TABLE]: null,
@ -555,3 +560,56 @@ export async function loadDocumentsFromServer(
return { lastSync: Date.now() }; return { lastSync: Date.now() };
} }
// Job management helpers
export async function createJob(job: Job): Promise<void> {
await withDB(async () => {
console.log('Creating job via Dexie:', job.id);
await db[JOBS_TABLE].put(job);
});
}
export async function getJob(id: string): Promise<Job | undefined> {
return withDB(async () => {
console.log('Fetching job via Dexie:', id);
return db[JOBS_TABLE].get(id);
});
}
export async function getAllJobs(): Promise<Job[]> {
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<Job[]> {
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<Job>): Promise<void> {
await withDB(async () => {
console.log('Updating job via Dexie:', id);
await db[JOBS_TABLE].update(id, updates);
});
}
export async function deleteJob(id: string): Promise<void> {
await withDB(async () => {
console.log('Deleting job via Dexie:', id);
await db[JOBS_TABLE].delete(id);
});
}
export async function clearCompletedJobs(): Promise<void> {
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);
});
}

46
src/types/jobs.ts Normal file
View file

@ -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;
}