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:
parent
45ae2f5000
commit
3862d0b0ad
3 changed files with 146 additions and 8 deletions
|
|
@ -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
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue