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:
parent
70ff7a4f68
commit
c333ec778a
3 changed files with 126 additions and 19 deletions
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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<ConfigRow, 'key'>;
|
||||
[APP_CONFIG_TABLE]: EntityTable<AppConfigRow, 'id'>;
|
||||
[LAST_LOCATION_TABLE]: EntityTable<LastLocationRow, 'docId'>;
|
||||
[JOBS_TABLE]: EntityTable<Job, 'id'>;
|
||||
};
|
||||
|
||||
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<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
46
src/types/jobs.ts
Normal 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;
|
||||
}
|
||||
Loading…
Reference in a new issue