From 3862d0b0ad5116cda49e282793f0a807009aa8ab Mon Sep 17 00:00:00 2001 From: Richard R Date: Mon, 4 May 2026 20:59:14 -0600 Subject: [PATCH] feat(tts): add leading context support for smart sentence splitting Introduce logic to provide previous text context for TTS sentence splitting, enabling more accurate prefix stripping and improved handling of segment boundaries. EPUB and PDF providers now supply previous segment text, and TTSContext leverages this context to avoid redundant continuation logic. This enhances TTS playback continuity and reduces repeated or truncated sentences across segment transitions. --- src/contexts/EPUBContext.tsx | 116 +++++++++++++++++++++++++++++++++++ src/contexts/PDFContext.tsx | 5 +- src/contexts/TTSContext.tsx | 33 +++++++--- 3 files changed, 146 insertions(+), 8 deletions(-) diff --git a/src/contexts/EPUBContext.tsx b/src/contexts/EPUBContext.tsx index 45d0662..d767c18 100644 --- a/src/contexts/EPUBContext.tsx +++ b/src/contexts/EPUBContext.tsx @@ -108,6 +108,35 @@ const stepToNextNode = (node: Node | null, root: Node): Node | null => { return null; }; +const stepToPreviousNode = (node: Node | null, root: Node): Node | null => { + if (!node) return null; + + if (node.previousSibling) { + let prev: Node | null = node.previousSibling; + while (prev?.lastChild) { + prev = prev.lastChild; + } + return prev; + } + + let current: Node | null = node.parentNode; + while (current) { + if (current === root) { + return null; + } + if (current.previousSibling) { + let prev: Node | null = current.previousSibling; + while (prev?.lastChild) { + prev = prev.lastChild; + } + return prev; + } + current = current.parentNode; + } + + return null; +}; + const getNextTextNode = (node: Node | null, root: Node): Text | null => { let next = stepToNextNode(node, root); while (next) { @@ -119,6 +148,31 @@ const getNextTextNode = (node: Node | null, root: Node): Text | null => { return null; }; +const getPreviousTextNode = (node: Node | null, root: Node): Text | null => { + let prev = stepToPreviousNode(node, root); + while (prev) { + if (prev.nodeType === Node.TEXT_NODE) { + return prev as Text; + } + prev = stepToPreviousNode(prev, root); + } + return null; +}; + +const getLastTextNodeInSubtree = (node: Node | null): Text | null => { + if (!node) return null; + if (node.nodeType === Node.TEXT_NODE) return node as Text; + + let child: Node | null = node.lastChild; + while (child) { + const nested = getLastTextNodeInSubtree(child); + if (nested) return nested; + child = child.previousSibling; + } + + return null; +}; + const collectContinuationFromRange = (range: Range | null | undefined, limit = EPUB_CONTINUATION_CHARS): string => { if (typeof window === 'undefined' || !range) { return ''; @@ -161,6 +215,66 @@ const collectContinuationFromRange = (range: Range | null | undefined, limit = E return parts.join(' ').replace(/\s+/g, ' ').trim(); }; +const collectLeadingContextFromRange = (range: Range | null | undefined, limit = EPUB_CONTINUATION_CHARS): string => { + if (typeof window === 'undefined' || !range) { + return ''; + } + + const root = range.commonAncestorContainer; + if (!root) { + return ''; + } + + const parts: string[] = []; + let remaining = limit; + + const prependFromTextNode = (textNode: Text, endOffset: number) => { + if (remaining <= 0) return; + const textContent = textNode.textContent || ''; + const safeEnd = Math.max(0, Math.min(endOffset, textContent.length)); + if (safeEnd <= 0) return; + const safeStart = Math.max(0, safeEnd - remaining); + const slice = textContent.slice(safeStart, safeEnd); + if (slice) { + parts.unshift(slice); + remaining -= slice.length; + } + }; + + let cursor: Node | null = null; + + if (range.startContainer.nodeType === Node.TEXT_NODE) { + const startText = range.startContainer as Text; + prependFromTextNode(startText, range.startOffset); + cursor = startText; + } else { + const startNode = range.startContainer; + let anchor: Node | null = null; + if (range.startOffset > 0) { + anchor = startNode.childNodes[range.startOffset - 1] ?? null; + } + if (!anchor) { + anchor = stepToPreviousNode(startNode, root); + } + + const anchorText = getLastTextNodeInSubtree(anchor); + if (anchorText) { + prependFromTextNode(anchorText, (anchorText.textContent || '').length); + cursor = anchorText; + } else { + cursor = anchor; + } + } + + let prevNode = getPreviousTextNode(cursor, root); + while (prevNode && remaining > 0) { + prependFromTextNode(prevNode, (prevNode.textContent || '').length); + prevNode = getPreviousTextNode(prevNode, root); + } + + return parts.join(' ').replace(/\s+/g, ' ').trim(); +}; + /** * Provider component for EPUB functionality * Manages the state and operations for EPUB document handling @@ -267,12 +381,14 @@ export function EPUBProvider({ children }: { children: ReactNode }) { return ''; } const textContent = range.toString().trim(); + const leadingPreview = collectLeadingContextFromRange(range); const continuationPreview = collectContinuationFromRange(range); if (smartSentenceSplitting) { setTTSText(textContent, { shouldPause, location: start.cfi, + previousText: leadingPreview, nextLocation: end.cfi, nextText: continuationPreview }); diff --git a/src/contexts/PDFContext.tsx b/src/contexts/PDFContext.tsx index c81dae3..d2b5899 100644 --- a/src/contexts/PDFContext.tsx +++ b/src/contexts/PDFContext.tsx @@ -237,10 +237,12 @@ export function PDFProvider({ children }: { children: ReactNode }) { }; const totalPages = currDocPages ?? currentPdf.numPages; + const prevPageNumber = currDocPageNumber > 1 ? currDocPageNumber - 1 : undefined; const nextPageNumber = currDocPageNumber < totalPages ? currDocPageNumber + 1 : undefined; - const [text, nextText] = await Promise.all([ + const [text, prevText, nextText] = await Promise.all([ getPageText(currDocPageNumber), + prevPageNumber ? getPageText(prevPageNumber) : Promise.resolve(undefined), nextPageNumber ? getPageText(nextPageNumber, true) : Promise.resolve(undefined), ]); @@ -281,6 +283,7 @@ export function PDFProvider({ children }: { children: ReactNode }) { setCurrDocText(text); setTTSText(text, { location: currDocPageNumber, + previousText: prevText, nextLocation: nextPageNumber, nextText: nextText, }); diff --git a/src/contexts/TTSContext.tsx b/src/contexts/TTSContext.tsx index a4b92c6..659aff5 100644 --- a/src/contexts/TTSContext.tsx +++ b/src/contexts/TTSContext.tsx @@ -100,6 +100,7 @@ interface SetTextOptions { location?: TTSLocation; nextLocation?: TTSLocation; nextText?: string; + previousText?: string; } type TTSSegmentPlaybackSource = { @@ -829,8 +830,22 @@ export function TTSProvider({ children }: { children: ReactNode }): ReactElement // Apply or clear sentence continuation logic based on config let continuationCarried: string | undefined; + let strippedByPreviousContext = false; if (smartSentenceSplitting) { - if (isEPUB && epubContinuationRef.current) { + if (typeof normalizedOptions.previousText === 'string' && normalizedOptions.previousText.trim()) { + const previousMerge = mergeContinuation(normalizedOptions.previousText, workingText); + if (previousMerge?.carried) { + const { text: strippedText, removed } = stripContinuationPrefix(workingText, previousMerge.carried); + if (removed) { + workingText = strippedText; + strippedByPreviousContext = true; + } + } + } + + if (isEPUB && strippedByPreviousContext) { + epubContinuationRef.current = null; + } else if (isEPUB && epubContinuationRef.current) { const { text: strippedText, removed } = stripContinuationPrefix(workingText, epubContinuationRef.current); workingText = strippedText; if (removed) { @@ -840,12 +855,16 @@ export function TTSProvider({ children }: { children: ReactNode }): ReactElement if (!isEPUB && normalizedOptions.location !== undefined) { const key = normalizeLocationKey(normalizedOptions.location); - const carried = continuationCarryRef.current.get(key); - if (carried) { - const { text: strippedText, removed } = stripContinuationPrefix(workingText, carried); - workingText = strippedText; - if (removed) { - continuationCarryRef.current.delete(key); + if (strippedByPreviousContext) { + continuationCarryRef.current.delete(key); + } else { + const carried = continuationCarryRef.current.get(key); + if (carried) { + const { text: strippedText, removed } = stripContinuationPrefix(workingText, carried); + workingText = strippedText; + if (removed) { + continuationCarryRef.current.delete(key); + } } } }