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.
This commit is contained in:
Richard R 2026-05-04 20:59:14 -06:00
parent 45ae2f5000
commit 3862d0b0ad
3 changed files with 146 additions and 8 deletions

View file

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

View file

@ -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<string | undefined>(undefined),
nextPageNumber ? getPageText(nextPageNumber, true) : Promise.resolve<string | undefined>(undefined),
]);
@ -281,6 +283,7 @@ export function PDFProvider({ children }: { children: ReactNode }) {
setCurrDocText(text);
setTTSText(text, {
location: currDocPageNumber,
previousText: prevText,
nextLocation: nextPageNumber,
nextText: nextText,
});

View file

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