From bb3eb409664d12c908525328cc0332429ada1d26 Mon Sep 17 00:00:00 2001 From: Richard R Date: Wed, 20 May 2026 04:44:47 -0600 Subject: [PATCH] refactor(tts): extend segment prefetching to support source unit arrays and enhance cache key granularity Update TTS segment prefetching logic to handle arrays of CanonicalTtsSourceUnit for upcoming and next locations, enabling more precise TTS segment mapping. Expand cache key construction to include providerType and instructions, improving cache differentiation for TTS requests. Adjust related types and usages to support richer segment metadata propagation. --- src/app/(app)/pdf/[id]/page.tsx | 15 +--- src/app/(app)/pdf/[id]/usePdfDocument.ts | 5 +- src/components/documents/DocumentSettings.tsx | 9 -- src/components/reader/SegmentsSidebar.tsx | 2 + src/contexts/TTSContext.tsx | 83 ++++++++++++++----- src/lib/client/audiobooks/adapters/pdf.ts | 6 -- src/lib/shared/document-settings.ts | 5 -- src/types/document-settings.ts | 2 - tests/unit/pdf-audiobook-adapter.spec.ts | 2 - 9 files changed, 69 insertions(+), 60 deletions(-) diff --git a/src/app/(app)/pdf/[id]/page.tsx b/src/app/(app)/pdf/[id]/page.tsx index 4cfb922..bad2b1e 100644 --- a/src/app/(app)/pdf/[id]/page.tsx +++ b/src/app/(app)/pdf/[id]/page.tsx @@ -337,7 +337,6 @@ export default function PDFViewerPage() { parseStatus, parsedOverlayEnabled, skipBlockKinds: documentSettings.pdf?.skipBlockKinds ?? [], - chaptersFromSections: documentSettings.pdf?.chaptersFromSections ?? true, onToggleOverlay: (enabled) => setParsedOverlayEnabled(enabled), onToggleSkipKind: (kind, enabled) => { const current = new Set(documentSettings.pdf?.skipBlockKinds ?? []); @@ -347,20 +346,8 @@ export default function PDFViewerPage() { ...documentSettings, schemaVersion: 1, pdf: { - ...(documentSettings.pdf ?? { chaptersFromSections: true }), + ...(documentSettings.pdf ?? {}), skipBlockKinds: Array.from(current), - chaptersFromSections: documentSettings.pdf?.chaptersFromSections ?? true, - }, - }); - }, - onToggleChaptersFromSections: (enabled) => { - void updateDocumentSettings({ - ...documentSettings, - schemaVersion: 1, - pdf: { - ...(documentSettings.pdf ?? { skipBlockKinds: [] }), - skipBlockKinds: documentSettings.pdf?.skipBlockKinds ?? [], - chaptersFromSections: enabled, }, }); }, diff --git a/src/app/(app)/pdf/[id]/usePdfDocument.ts b/src/app/(app)/pdf/[id]/usePdfDocument.ts index f8f5cca..5aafa0a 100644 --- a/src/app/(app)/pdf/[id]/usePdfDocument.ts +++ b/src/app/(app)/pdf/[id]/usePdfDocument.ts @@ -275,7 +275,7 @@ export function usePdfDocument(): PdfDocumentState { text: block.text, locator: { readerType: 'pdf', - page: block.fragments[0]?.page ?? pageNum, + page: pageNum, blockId: block.id, } as TTSSegmentLocator, })) @@ -328,11 +328,13 @@ export function usePdfDocument(): PdfDocumentState { ...upcomingPageNumbers.map((pageNum) => getPageText(pageNum, true)), ]); const nextText = upcomingTexts[0]; + const nextSourceUnits = nextPageNumber ? sourceUnitsFromParsedPage(nextPageNumber) : []; const additionalUpcoming = upcomingPageNumbers .slice(1) .map((pageNum, idx) => ({ location: pageNum, text: upcomingTexts[idx + 1] || '', + sourceUnits: sourceUnitsFromParsedPage(pageNum), })) .filter((item) => item.text.trim().length > 0); @@ -351,6 +353,7 @@ export function usePdfDocument(): PdfDocumentState { previousText: prevText, nextLocation: nextPageNumber, nextText: nextText, + nextSourceUnits, upcomingLocations: additionalUpcoming, ...(sourceUnits.length > 0 ? { sourceUnits } : {}), }); diff --git a/src/components/documents/DocumentSettings.tsx b/src/components/documents/DocumentSettings.tsx index 9a5ec8a..27d1576 100644 --- a/src/components/documents/DocumentSettings.tsx +++ b/src/components/documents/DocumentSettings.tsx @@ -103,10 +103,8 @@ export function DocumentSettings({ isOpen, setIsOpen, epub, html, pdf }: { parseStatus: PdfParseStatus | null; parsedOverlayEnabled: boolean; skipBlockKinds: ParsedPdfBlockKind[]; - chaptersFromSections: boolean; onToggleOverlay: (enabled: boolean) => void; onToggleSkipKind: (kind: ParsedPdfBlockKind, enabled: boolean) => void; - onToggleChaptersFromSections: (enabled: boolean) => void; onForceReparse: () => void; } }) { @@ -204,13 +202,6 @@ export function DocumentSettings({ isOpen, setIsOpen, epub, html, pdf }: { onChange={(checked) => updateConfigKey('pdfWordHighlightEnabled', checked)} variant="flat" /> - )} diff --git a/src/components/reader/SegmentsSidebar.tsx b/src/components/reader/SegmentsSidebar.tsx index 1e79ccc..5c9b2f1 100644 --- a/src/components/reader/SegmentsSidebar.tsx +++ b/src/components/reader/SegmentsSidebar.tsx @@ -249,6 +249,8 @@ export function SegmentsSidebar({ isOpen, setIsOpen, documentId, epubBookRef }: const segmentsQuery = useInfiniteQuery({ queryKey: segmentsQueryKey, enabled: isOpen && !!documentId, + refetchInterval: isOpen ? 2500 : false, + refetchIntervalInBackground: false, initialPageParam: null as string | null, queryFn: async ({ pageParam, signal }) => { if (!documentId) { diff --git a/src/contexts/TTSContext.tsx b/src/contexts/TTSContext.tsx index d96d23e..b380bcd 100644 --- a/src/contexts/TTSContext.tsx +++ b/src/contexts/TTSContext.tsx @@ -163,8 +163,9 @@ interface SetTextOptions { previousLocation?: TTSLocation; nextLocation?: TTSLocation; nextText?: string; + nextSourceUnits?: CanonicalTtsSourceUnit[]; previousText?: string; - upcomingLocations?: Array<{ location: TTSLocation; text: string }>; + upcomingLocations?: Array<{ location: TTSLocation; text: string; sourceUnits?: CanonicalTtsSourceUnit[] }>; } type TTSSegmentPlaybackSource = { @@ -245,12 +246,16 @@ const buildCacheKey = ( speed: number, provider: string, model: string, + providerType: string, + instructions: string, ) => { return [ `provider=${provider || ''}`, + `providerType=${providerType || ''}`, `model=${model || ''}`, `voice=${voice || ''}`, `speed=${Number.isFinite(speed) ? speed : ''}`, + `instructions=${instructions || ''}`, `text=${sentence}`, ].join('|'); }; @@ -263,10 +268,12 @@ const buildScopedSegmentCacheKey = ( speed: number, provider: string, model: string, + providerType: string, + instructions: string, segmentKey?: string | null, ) => { return [ - buildCacheKey(sentence, voice, speed, provider, model), + buildCacheKey(sentence, voice, speed, provider, model, providerType, instructions), `segmentKey=${segmentKey || ''}`, `locator=${segmentKey ? '' : buildLocatorRequestKey(locator)}`, `segmentIndex=${segmentKey ? '' : segmentIndex}`, @@ -306,9 +313,11 @@ const buildSegmentRequestKey = ( segmentIndex: number, sentence: string, segmentKey?: string | null, -): string => segmentKey - ? `${segmentKey}::${sentence}` - : `${buildLocatorRequestKey(locator)}::${segmentIndex}::${sentence}`; +): string => { + return segmentKey + ? `${segmentKey}::${sentence}` + : `${buildLocatorRequestKey(locator)}::${segmentIndex}::${sentence}`; +}; const resolveJumpIndex = (input: JumpResolutionInput): JumpResolution => { if (input.newSentenceCount <= 0) { @@ -1172,29 +1181,48 @@ export function TTSProvider({ children }: { children: ReactNode }): ReactElement plannedSegmentsByLocationRef.current.clear(); pendingNextLocationRef.current = normalizedOptions.nextLocation; - const pendingPrefetches: Array = []; + const pendingPrefetches: Array<{ + location: TTSLocation; + sourceUnits: CanonicalTtsSourceUnit[]; + }> = []; if (normalizedOptions.nextLocation !== undefined && normalizedOptions.nextText?.trim()) { - pendingPrefetches.push({ - sourceKey: sourceKeyForLocation(normalizedOptions.nextLocation, currDocPage), - location: normalizedOptions.nextLocation, - text: normalizedOptions.nextText, - locator: locatorForLocation(normalizedOptions.nextLocation, activeReaderType), - }); + const provided = normalizedOptions.nextSourceUnits?.filter((unit) => unit.text.trim().length > 0) ?? []; + pendingPrefetches.push(provided.length > 0 + ? { + location: normalizedOptions.nextLocation, + sourceUnits: provided, + } + : { + location: normalizedOptions.nextLocation, + sourceUnits: [{ + sourceKey: sourceKeyForLocation(normalizedOptions.nextLocation, currDocPage), + text: normalizedOptions.nextText, + locator: locatorForLocation(normalizedOptions.nextLocation, activeReaderType), + }], + }); } if (Array.isArray(normalizedOptions.upcomingLocations)) { for (const item of normalizedOptions.upcomingLocations) { if (item.location === undefined || !item.text?.trim()) continue; - pendingPrefetches.push({ - sourceKey: sourceKeyForLocation(item.location, currDocPage), - location: item.location, - text: item.text, - locator: locatorForLocation(item.location, activeReaderType), - }); + const provided = item.sourceUnits?.filter((unit) => unit.text.trim().length > 0) ?? []; + pendingPrefetches.push(provided.length > 0 + ? { + location: item.location, + sourceUnits: provided, + } + : { + location: item.location, + sourceUnits: [{ + sourceKey: sourceKeyForLocation(item.location, currDocPage), + text: item.text, + locator: locatorForLocation(item.location, activeReaderType), + }], + }); } } for (const item of pendingPrefetches) { if (smartSentenceSplitting) { - sourceUnits.push(item); + sourceUnits.push(...item.sourceUnits); } } @@ -1216,9 +1244,10 @@ export function TTSProvider({ children }: { children: ReactNode }): ReactElement const newSentences = currentSegments.map((segment) => segment.text); for (const item of pendingPrefetches) { + const sourceKeys = new Set(item.sourceUnits.map((unit) => unit.sourceKey)); const planned = smartSentenceSplitting - ? plan.segments.filter((segment) => segment.ownerSourceKey === item.sourceKey) - : planCanonicalTtsSegments([item], { + ? plan.segments.filter((segment) => sourceKeys.has(segment.ownerSourceKey)) + : planCanonicalTtsSegments(item.sourceUnits, { readerType: activeReaderType, maxBlockLength: ttsSegmentMaxBlockLength, keyPrefix: buildSegmentKeyPrefix(documentId, activeReaderType), @@ -1554,6 +1583,8 @@ export function TTSProvider({ children }: { children: ReactNode }): ReactElement effectiveNativeSpeed, configProviderRef, ttsModel, + configProviderType, + providerModelPolicy.supportsInstructions ? ttsInstructions : '', segmentKey, ); @@ -2216,6 +2247,8 @@ export function TTSProvider({ children }: { children: ReactNode }): ReactElement effectiveNativeSpeed, configProviderRef, ttsModel, + configProviderType, + providerModelPolicy.supportsInstructions ? ttsInstructions : '', playbackSegment?.key, ); const cachedAlignment = sentenceAlignmentCacheRef.current.get(alignmentKey); @@ -2346,6 +2379,8 @@ export function TTSProvider({ children }: { children: ReactNode }): ReactElement effectiveNativeSpeed, configProviderRef, ttsModel, + configProviderType, + providerModelPolicy.supportsInstructions ? ttsInstructions : '', nextSegment?.key, ); if (segmentManifestCacheRef.current.has(cacheKey)) { @@ -2477,6 +2512,8 @@ export function TTSProvider({ children }: { children: ReactNode }): ReactElement effectiveNativeSpeed, configProviderRef, ttsModel, + configProviderType, + providerModelPolicy.supportsInstructions ? ttsInstructions : '', segment.key, ); if (seenCandidates.has(requestKey)) continue; @@ -2615,6 +2652,8 @@ export function TTSProvider({ children }: { children: ReactNode }): ReactElement effectiveNativeSpeed, configProviderRef, ttsModel, + configProviderType, + providerModelPolicy.supportsInstructions ? ttsInstructions : '', plannedSegment?.key, ); const requestKey = buildSegmentRequestKey(locator, sentenceIndex, sentence, plannedSegment?.key); @@ -2644,6 +2683,8 @@ export function TTSProvider({ children }: { children: ReactNode }): ReactElement effectiveNativeSpeed, configProviderRef, ttsModel, + configProviderType, + providerModelPolicy.supportsInstructions ? ttsInstructions : '', segment.key, ); const requestKey = buildSegmentRequestKey(segmentLocator, index, sentence, segment.key); diff --git a/src/lib/client/audiobooks/adapters/pdf.ts b/src/lib/client/audiobooks/adapters/pdf.ts index 9aff5ac..a462c32 100644 --- a/src/lib/client/audiobooks/adapters/pdf.ts +++ b/src/lib/client/audiobooks/adapters/pdf.ts @@ -41,12 +41,6 @@ function prepareParsedChapters({ .filter((block) => !skip.has(block.kind)); if (!allBlocks.length) return []; - const chaptersFromSections = settings.pdf?.chaptersFromSections ?? true; - if (!chaptersFromSections) { - const text = chapterTextFromBlocks(allBlocks, smartSentenceSplitting, maxBlockLength); - return text ? [{ index: 0, title: 'Document', text }] : []; - } - const chapters: PreparedAudiobookChapter[] = []; let currentTitle = 'Introduction'; let currentBlocks: ParsedPdfBlock[] = []; diff --git a/src/lib/shared/document-settings.ts b/src/lib/shared/document-settings.ts index 323a039..26b45e0 100644 --- a/src/lib/shared/document-settings.ts +++ b/src/lib/shared/document-settings.ts @@ -24,7 +24,6 @@ export function mergeDocumentSettings( schemaVersion: 1, pdf: { skipBlockKinds: [...(defaults.pdf?.skipBlockKinds ?? [])], - chaptersFromSections: defaults.pdf?.chaptersFromSections ?? true, }, }; @@ -38,10 +37,6 @@ export function mergeDocumentSettings( schemaVersion: 1, pdf: { skipBlockKinds: normalizeSkipKinds(pdfRec.skipBlockKinds), - chaptersFromSections: - typeof pdfRec.chaptersFromSections === 'boolean' - ? pdfRec.chaptersFromSections - : (base.pdf?.chaptersFromSections ?? true), }, }; } diff --git a/src/types/document-settings.ts b/src/types/document-settings.ts index 1bfd1ba..6908f33 100644 --- a/src/types/document-settings.ts +++ b/src/types/document-settings.ts @@ -4,7 +4,6 @@ export interface DocumentSettings { schemaVersion: 1; pdf?: { skipBlockKinds: ParsedPdfBlockKind[]; - chaptersFromSections: boolean; }; } @@ -12,6 +11,5 @@ export const DEFAULT_DOCUMENT_SETTINGS: DocumentSettings = { schemaVersion: 1, pdf: { skipBlockKinds: ['header', 'footer', 'footnote', 'vision_footnote'], - chaptersFromSections: true, }, }; diff --git a/tests/unit/pdf-audiobook-adapter.spec.ts b/tests/unit/pdf-audiobook-adapter.spec.ts index 20c7114..4acdeaa 100644 --- a/tests/unit/pdf-audiobook-adapter.spec.ts +++ b/tests/unit/pdf-audiobook-adapter.spec.ts @@ -55,7 +55,6 @@ test.describe('pdf audiobook adapter', () => { schemaVersion: 1, pdf: { skipBlockKinds: ['header'], - chaptersFromSections: true, }, }; @@ -120,7 +119,6 @@ test.describe('pdf audiobook adapter', () => { schemaVersion: 1, pdf: { skipBlockKinds: [], - chaptersFromSections: true, }, };