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
|
// 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) {
|
||||||
|
|
|
||||||
|
|
@ -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
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