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.
This commit is contained in:
Richard R 2026-05-20 04:44:47 -06:00
parent 9d1f9d5707
commit bb3eb40966
9 changed files with 69 additions and 60 deletions

View file

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

View file

@ -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 } : {}),
});

View file

@ -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"
/>
<ToggleRow
label="Use detected sections as chapters"
description="Build audiobook chapters from section headers."
checked={pdf.chaptersFromSections}
onChange={pdf.onToggleChaptersFromSections}
variant="flat"
/>
</Section>
)}

View file

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

View file

@ -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<CanonicalTtsSourceUnit & { location: TTSLocation }> = [];
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);

View file

@ -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[] = [];

View file

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

View file

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

View file

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