diff --git a/src/app/api/tts/segments/ensure/route.ts b/src/app/api/tts/segments/ensure/route.ts index 948693a..f524dc0 100644 --- a/src/app/api/tts/segments/ensure/route.ts +++ b/src/app/api/tts/segments/ensure/route.ts @@ -81,6 +81,9 @@ function parseSegments(value: unknown): TTSSegmentInput[] | null { if (typeof rec.text !== 'string') return null; parsed.push({ segmentIndex: Number(rec.segmentIndex), + ...(typeof rec.segmentKey === 'string' && rec.segmentKey.trim() + ? { segmentKey: rec.segmentKey.trim() } + : {}), text: rec.text, ...(rec.locator && typeof rec.locator === 'object' ? { locator: rec.locator as TTSSegmentInput['locator'] } : {}), }); @@ -231,6 +234,7 @@ export async function POST(request: NextRequest) { documentVersion: scope.documentVersion, settingsHash, segmentIndex: segment.segmentIndex, + segmentKey: segment.segmentKey, normalizedText: text, locatorFingerprint: locatorHash, }); @@ -334,6 +338,7 @@ export async function POST(request: NextRequest) { manifest.push({ segmentId: segment.segmentId, segmentIndex: existing.segmentIndex, + segmentKey: segment.original.segmentKey ?? null, ...buildSegmentAudioUrls(parsed.documentId, segment.segmentId), durationMs: existing.durationMs ?? 0, alignment, @@ -506,6 +511,7 @@ export async function POST(request: NextRequest) { manifest.push({ segmentId: segment.segmentId, segmentIndex: segment.original.segmentIndex, + segmentKey: segment.original.segmentKey ?? null, ...buildSegmentAudioUrls(parsed.documentId, segment.segmentId), durationMs, alignment, @@ -527,6 +533,7 @@ export async function POST(request: NextRequest) { manifest.push({ segmentId: segment.segmentId, segmentIndex: segment.original.segmentIndex, + segmentKey: segment.original.segmentKey ?? null, audioPresignUrl: null, audioFallbackUrl: null, durationMs: 0, diff --git a/src/components/reader/SegmentsSidebar.tsx b/src/components/reader/SegmentsSidebar.tsx index 328b21c..89aafb9 100644 --- a/src/components/reader/SegmentsSidebar.tsx +++ b/src/components/reader/SegmentsSidebar.tsx @@ -7,7 +7,7 @@ import { useTTS } from '@/contexts/TTSContext'; import { useConfig } from '@/contexts/ConfigContext'; import { RefreshIcon, InfoIcon } from '@/components/icons/Icons'; import { ReaderSidebarShell } from '@/components/reader/ReaderSidebarShell'; -import { locatorGroupKey, normalizeEpubLocationToken } from '@/lib/shared/tts-locator'; +import { compareSegmentLocators, locatorGroupKey, normalizeEpubLocationToken } from '@/lib/shared/tts-locator'; import type { TTSSegmentLocator, TTSSegmentRow, @@ -122,13 +122,8 @@ function formatLocatorGroupLabel(locator: TTSSegmentLocator | null): string { } function compareRows(a: TTSSegmentRow, b: TTSSegmentRow): number { - const aPage = typeof a.locator?.page === 'number' ? a.locator.page : Number.MAX_SAFE_INTEGER; - const bPage = typeof b.locator?.page === 'number' ? b.locator.page : Number.MAX_SAFE_INTEGER; - if (aPage !== bPage) return aPage - bPage; - const aLoc = a.locator?.location || ''; - const bLoc = b.locator?.location || ''; - const byLocation = aLoc.localeCompare(bLoc); - if (byLocation !== 0) return byLocation; + const byLocator = compareSegmentLocators(a.locator, b.locator); + if (byLocator !== 0) return byLocator; if (a.segmentIndex !== b.segmentIndex) return a.segmentIndex - b.segmentIndex; return locatorGroupKey(a.locator).localeCompare(locatorGroupKey(b.locator)); } diff --git a/src/components/views/EPUBViewer.tsx b/src/components/views/EPUBViewer.tsx index 43f4798..5e706e6 100644 --- a/src/components/views/EPUBViewer.tsx +++ b/src/components/views/EPUBViewer.tsx @@ -33,7 +33,7 @@ export function EPUBViewer({ className = '' }: EPUBViewerProps) { tocRef, setRendition, extractPageText, - highlightPattern, + highlightSegment, clearHighlights, highlightWordIndex, clearWordHighlights, @@ -43,7 +43,7 @@ export function EPUBViewer({ className = '' }: EPUBViewerProps) { registerLocationChangeHandler, registerEpubLocationWalker, pause, - currentSentence, + currentSegment, currentSentenceAlignment, currentWordIndex } = useTTS(); @@ -82,12 +82,12 @@ export function EPUBViewer({ className = '' }: EPUBViewerProps) { // Handle highlighting useEffect(() => { - if (currentSentence) { - highlightPattern(currentSentence); + if (currentSegment) { + highlightSegment(currentSegment); } else { clearHighlights(); } - }, [currentSentence, highlightPattern, clearHighlights]); + }, [currentSegment, highlightSegment, clearHighlights]); // Word-level highlight layered on top of the block highlight useEffect(() => { @@ -109,11 +109,11 @@ export function EPUBViewer({ className = '' }: EPUBViewerProps) { highlightWordIndex( currentSentenceAlignment, currentWordIndex, - currentSentence || '' + currentSegment ); }, [ currentWordIndex, - currentSentence, + currentSegment, currentSentenceAlignment, epubHighlightEnabled, epubWordHighlightEnabled, diff --git a/src/contexts/EPUBContext.tsx b/src/contexts/EPUBContext.tsx index f660c3a..42d0152 100644 --- a/src/contexts/EPUBContext.tsx +++ b/src/contexts/EPUBContext.tsx @@ -26,9 +26,15 @@ import { EpubRenderedLocationCloneManager } from '@/lib/client/epub/rendered-loc import { useTTS } from '@/contexts/TTSContext'; import { useAuthConfig } from '@/contexts/AuthRateLimitContext'; import { createRangeCfi } from '@/lib/client/epub'; +import { normalizeTtsLocationKey } from '@/lib/shared/tts-locator'; +import { + buildMonotonicWordToTokenMap, + buildWordHighlightCacheKey, + tokenizeCanonicalSegment, + type EpubCanonicalWordToken, +} from '@/lib/shared/epub-word-highlight'; import { useParams } from 'next/navigation'; import { useConfig } from './ConfigContext'; -import { CmpStr } from 'cmpstr'; import type { EpubRenderedLocationWalker, TTSSentenceAlignment, @@ -36,6 +42,7 @@ import type { TTSAudiobookChapter, } from '@/types/tts'; import type { AudiobookGenerationSettings } from '@/types/client'; +import type { CanonicalTtsSegment } from '@/lib/shared/tts-segment-plan'; interface EPUBContextType { currDocData: ArrayBuffer | undefined; @@ -69,12 +76,12 @@ interface EPUBContextType { handleLocationChanged: (location: string | number) => void; setRendition: (rendition: Rendition) => void; isAudioCombining: boolean; - highlightPattern: (text: string) => void; + highlightSegment: (segment: CanonicalTtsSegment | null | undefined) => void; clearHighlights: () => void; highlightWordIndex: ( alignment: TTSSentenceAlignment | undefined, wordIndex: number | null | undefined, - sentence: string | null | undefined + segment: CanonicalTtsSegment | null | undefined ) => void; clearWordHighlights: () => void; } @@ -83,14 +90,6 @@ const EPUBContext = createContext(undefined); const EPUB_CONTINUATION_CHARS = 5000; -const cmp = CmpStr.create().setMetric('dice').setFlags('itw'); - -const normalizeWordForMatch = (text: string): string => - text - .trim() - .replace(/^[^A-Za-z0-9]+|[^A-Za-z0-9]+$/g, '') - .toLowerCase(); - const stepToNextNode = (node: Node | null, root: Node): Node | null => { if (!node) return null; if (node.firstChild) { @@ -278,6 +277,250 @@ const collectLeadingContextFromRange = (range: Range | null | undefined, limit = return parts.join(' ').replace(/\s+/g, ' ').trim(); }; +type EpubMappedPosition = { + node: Text; + offset: number; +}; + +type EpubMappedChar = { + char: string; + position: EpubMappedPosition; +}; + +type EpubRenderedTextMap = { + sourceKey: string; + chars: EpubMappedPosition[]; + content: { + cfiFromRange: (range: Range) => string; + }; +}; + +type EpubWordHighlightMapCache = { + key: string; + wordToToken: number[]; + tokens: EpubCanonicalWordToken[]; +}; + +const cloneMappedChar = (char: string, source: EpubMappedChar): EpubMappedChar => ({ + char, + position: source.position, +}); + +const replaceMappedUrls = (tokens: EpubMappedChar[]): EpubMappedChar[] => { + const text = tokens.map((token) => token.char).join(''); + const urlPattern = /\S*(?:https?:\/\/|www\.)([^\/\s]+)(?:\/\S*)?/gi; + const replaced: EpubMappedChar[] = []; + let cursor = 0; + let match: RegExpExecArray | null; + + while ((match = urlPattern.exec(text)) !== null) { + const start = match.index; + const end = start + match[0].length; + replaced.push(...tokens.slice(cursor, start)); + + const anchor = tokens[start] ?? tokens[Math.max(0, end - 1)]; + if (anchor) { + const replacement = `- (link to ${match[1]}) -`; + for (const char of replacement) { + replaced.push(cloneMappedChar(char, anchor)); + } + } + cursor = end; + } + + replaced.push(...tokens.slice(cursor)); + return replaced; +}; + +const removeMappedHyphenation = (tokens: EpubMappedChar[]): EpubMappedChar[] => { + const text = tokens.map((token) => token.char).join(''); + const hyphenPattern = /(\w+)-\s+(\w+)/g; + const replaced: EpubMappedChar[] = []; + let cursor = 0; + let match: RegExpExecArray | null; + + while ((match = hyphenPattern.exec(text)) !== null) { + const start = match.index; + const full = match[0]; + const first = match[1]; + const second = match[2]; + const secondOffset = full.lastIndexOf(second); + + replaced.push(...tokens.slice(cursor, start)); + replaced.push(...tokens.slice(start, start + first.length)); + replaced.push(...tokens.slice(start + secondOffset, start + secondOffset + second.length)); + cursor = start + full.length; + } + + replaced.push(...tokens.slice(cursor)); + return replaced; +}; + +const normalizeMappedTokensForTts = (tokens: EpubMappedChar[]): EpubMappedChar[] => { + const withoutLinks = replaceMappedUrls(tokens); + const withoutHyphenation = removeMappedHyphenation(withoutLinks); + const normalized: EpubMappedChar[] = []; + let pendingWhitespace: EpubMappedChar | null = null; + + const flushWhitespace = () => { + if (!pendingWhitespace || normalized.length === 0 || normalized[normalized.length - 1].char === ' ') { + pendingWhitespace = null; + return; + } + normalized.push(cloneMappedChar(' ', pendingWhitespace)); + pendingWhitespace = null; + }; + + for (const token of withoutHyphenation) { + if (token.char === '*') continue; + if (/\s/.test(token.char)) { + pendingWhitespace ??= token; + continue; + } + + flushWhitespace(); + normalized.push(token); + } + + if (normalized[normalized.length - 1]?.char === ' ') { + normalized.pop(); + } + + return normalized; +}; + +const collectMappedTextFromRange = (range: Range): EpubMappedChar[] => { + const root = range.commonAncestorContainer; + const doc = range.startContainer.ownerDocument ?? (range.startContainer as Document); + const mapped: EpubMappedChar[] = []; + + const addTextSlice = (textNode: Text, start: number, end: number) => { + const text = textNode.textContent || ''; + const safeStart = Math.max(0, Math.min(start, text.length)); + const safeEnd = Math.max(safeStart, Math.min(end, text.length)); + for (let offset = safeStart; offset < safeEnd; offset += 1) { + mapped.push({ + char: text[offset], + position: { node: textNode, offset }, + }); + } + }; + + if (root.nodeType === Node.TEXT_NODE) { + addTextSlice(root as Text, range.startOffset, range.endOffset); + return mapped; + } + + const nodeFilter = doc.defaultView?.NodeFilter ?? NodeFilter; + const walker = doc.createTreeWalker( + root, + nodeFilter.SHOW_TEXT, + { + acceptNode: (node) => { + try { + return range.intersectsNode(node) + ? nodeFilter.FILTER_ACCEPT + : nodeFilter.FILTER_REJECT; + } catch { + return nodeFilter.FILTER_REJECT; + } + }, + }, + ); + + let textNode = walker.nextNode() as Text | null; + while (textNode) { + const text = textNode.textContent || ''; + let start = 0; + let end = text.length; + + if (textNode === range.startContainer) { + start = range.startOffset; + } + if (textNode === range.endContainer) { + end = range.endOffset; + } + + addTextSlice(textNode, start, end); + textNode = walker.nextNode() as Text | null; + } + + return mapped; +}; + +const buildRenderedTextMaps = ( + rendition: Rendition, + rangeCfi: string, + sourceKey: string, +): EpubRenderedTextMap[] => { + const contents = rendition.getContents(); + const contentsArray = Array.isArray(contents) ? contents : [contents]; + const maps: EpubRenderedTextMap[] = []; + + for (const content of contentsArray) { + try { + const range = content.range(rangeCfi); + if (!range) continue; + + const normalized = normalizeMappedTokensForTts(collectMappedTextFromRange(range)); + if (!normalized.length) continue; + + maps.push({ + sourceKey, + chars: normalized.map((token) => token.position), + content, + }); + } catch { + // Not every displayed iframe can resolve every CFI in spread mode. + } + } + + return maps; +}; + +const createRangeFromMappedOffsets = ( + map: EpubRenderedTextMap, + startOffset: number, + endOffset: number, +): Range | null => { + const start = Math.max(0, Math.min(startOffset, map.chars.length)); + const end = Math.max(start, Math.min(endOffset, map.chars.length)); + if (end <= start) return null; + + const startPosition = map.chars[start]; + const endPosition = map.chars[end - 1]; + if (!startPosition || !endPosition) return null; + + const doc = startPosition.node.ownerDocument; + const range = doc.createRange(); + range.setStart(startPosition.node, startPosition.offset); + range.setEnd(endPosition.node, endPosition.offset + 1); + return range; +}; + +const resolveVisibleSegmentRange = ( + maps: EpubRenderedTextMap[], + segment: CanonicalTtsSegment | null | undefined, +): { map: EpubRenderedTextMap; range: Range; startOffset: number; endOffset: number } | null => { + if (!segment) return null; + + for (const map of maps) { + const startsInMap = segment.startAnchor.sourceKey === map.sourceKey; + const endsInMap = segment.endAnchor.sourceKey === map.sourceKey; + if (!startsInMap && !endsInMap) continue; + + const startOffset = startsInMap ? segment.startAnchor.offset : 0; + const endOffset = endsInMap ? segment.endAnchor.offset : map.chars.length; + const range = createRangeFromMappedOffsets(map, startOffset, endOffset); + if (range) { + return { map, range, startOffset, endOffset }; + } + } + + return null; +}; + + /** * Provider component for EPUB functionality * Manages the state and operations for EPUB document handling @@ -314,6 +557,8 @@ export function EPUBProvider({ children }: { children: ReactNode }) { // Track current highlight CFI for removal const currentHighlightCfi = useRef(null); const currentWordHighlightCfi = useRef(null); + const renderedTextMapsRef = useRef([]); + const wordHighlightMapCacheRef = useRef(null); const renderedLocationCloneManagerRef = useRef( new EpubRenderedLocationCloneManager(), ); @@ -339,6 +584,8 @@ export function EPUBProvider({ children }: { children: ReactNode }) { renditionRef.current = undefined; locationRef.current = 1; tocRef.current = []; + renderedTextMapsRef.current = []; + wordHighlightMapCacheRef.current = null; renderedLocationCloneManagerRef.current.invalidate(); stop(); }, [setCurrDocPages, stop]); @@ -386,7 +633,9 @@ export function EPUBProvider({ children }: { children: ReactNode }) { */ const extractPageText = useCallback(async (book: Book, rendition: Rendition, shouldPause = false): Promise => { try { - const { start, end } = rendition?.location; + const location = rendition?.location; + if (!location) return ''; + const { start, end } = location; if (!start?.cfi || !end?.cfi || !book || !book.isOpen || !rendition) return ''; const rangeCfi = createRangeCfi(start.cfi, end.cfi); @@ -397,6 +646,12 @@ export function EPUBProvider({ children }: { children: ReactNode }) { return ''; } const textContent = range.toString().trim(); + renderedTextMapsRef.current = buildRenderedTextMaps( + rendition, + rangeCfi, + normalizeTtsLocationKey(start.cfi), + ); + wordHighlightMapCacheRef.current = null; const leadingPreview = collectLeadingContextFromRange(range); const continuationPreview = collectContinuationFromRange(range); @@ -714,53 +969,29 @@ export function EPUBProvider({ children }: { children: ReactNode }) { clearWordHighlights(); }, [clearWordHighlights]); - const highlightPattern = useCallback(async (text: string) => { + const highlightSegment = useCallback((segment: CanonicalTtsSegment | null | undefined) => { if (!renditionRef.current) return; - // Clear existing highlights first clearHighlights(); - if (!epubHighlightEnabled) return; + if (!epubHighlightEnabled || !segment) return; - if (!text || !text.trim()) return; + const resolved = resolveVisibleSegmentRange(renderedTextMapsRef.current, segment); + if (!resolved) return; try { - const contents = renditionRef.current.getContents(); - const contentsArray = Array.isArray(contents) ? contents : [contents]; - for (const content of contentsArray) { - const win = content.window; - if (win && win.find) { - // Reset selection to start of document to ensure full search - const sel = win.getSelection(); - sel?.removeAllRanges(); - - // Attempt to find the text - // window.find(aString, aCaseSensitive, aBackwards, aWrapAround, aWholeWord, aSearchInFrames, aShowDialog); - // Note: We search for the trimmed text. - if (win.find(text.trim(), false, false, true, false, false, false)) { - const range = sel?.getRangeAt(0); - if (range) { - const cfi = content.cfiFromRange(range); - // Store CFI for removal - currentHighlightCfi.current = cfi; - renditionRef.current.annotations.add( - 'highlight', - cfi, - {}, - () => {}, - '', - { fill: 'grey', 'fill-opacity': '0.4', 'mix-blend-mode': 'multiply' }, - ); - - // Clear the browser selection so it doesn't look like user selected text - sel?.removeAllRanges(); - return; // Stop after first match - } - } - } - } + const cfi = resolved.map.content.cfiFromRange(resolved.range); + currentHighlightCfi.current = cfi; + renditionRef.current.annotations.add( + 'highlight', + cfi, + {}, + () => { }, + '', + { fill: 'grey', 'fill-opacity': '0.4', 'mix-blend-mode': 'multiply' }, + ); } catch (error) { - console.error('Error highlighting text:', error); + console.error('Error highlighting EPUB segment:', error); } }, [clearHighlights, epubHighlightEnabled]); @@ -774,7 +1005,7 @@ export function EPUBProvider({ children }: { children: ReactNode }) { const highlightWordIndex = useCallback(( alignment: TTSSentenceAlignment | undefined, wordIndex: number | null | undefined, - sentence: string | null | undefined + segment: CanonicalTtsSegment | null | undefined ) => { clearWordHighlights(); @@ -786,244 +1017,50 @@ export function EPUBProvider({ children }: { children: ReactNode }) { if (!words.length || wordIndex >= words.length) return; if (!renditionRef.current) return; - if (!currentHighlightCfi.current) return; - const cleanSentence = - sentence && sentence.trim() - ? sentence.trim().replace(/\s+/g, ' ') - : null; - if (!cleanSentence) return; + if (!segment || segment.startAnchor.sourceKey !== segment.ownerSourceKey) return; - const alignmentSentenceClean = alignment.sentence - ? alignment.sentence.trim().replace(/\s+/g, ' ') - : null; - if (!alignmentSentenceClean || alignmentSentenceClean !== cleanSentence) { - return; + const resolved = resolveVisibleSegmentRange(renderedTextMapsRef.current, segment); + if (!resolved || segment.startAnchor.sourceKey !== resolved.map.sourceKey) return; + + const cacheKey = buildWordHighlightCacheKey(segment, alignment); + if (wordHighlightMapCacheRef.current?.key !== cacheKey) { + const tokens = tokenizeCanonicalSegment(segment); + wordHighlightMapCacheRef.current = { + key: cacheKey, + tokens, + wordToToken: buildMonotonicWordToTokenMap(words, tokens), + }; } - const contents = renditionRef.current.getContents(); - const contentsArray = Array.isArray(contents) ? contents : [contents]; + const cached = wordHighlightMapCacheRef.current; + const tokenIndex = cached.wordToToken[wordIndex] ?? -1; + if (tokenIndex < 0) return; - for (const content of contentsArray) { - let range: Range | null = null; - try { - range = content.range(currentHighlightCfi.current as string); - } catch { - range = null; - } - if (!range) continue; + const token = cached.tokens[tokenIndex]; + if (!token) return; + if (token.sourceStart < resolved.startOffset || token.sourceEnd > resolved.endOffset) return; - const root = range.commonAncestorContainer; - if (!root) continue; + const wordRange = createRangeFromMappedOffsets(resolved.map, token.sourceStart, token.sourceEnd); + if (!wordRange) return; - const domTokens: Array<{ - node: Text; - startOffset: number; - endOffset: number; - norm: string; - }> = []; - - const addTokensFromNode = (textNode: Text, start: number, end: number) => { - const full = textNode.textContent || ''; - const safeStart = Math.max(0, Math.min(start, full.length)); - const safeEnd = Math.max(safeStart, Math.min(end, full.length)); - if (safeEnd <= safeStart) return; - - const slice = full.slice(safeStart, safeEnd); - const wordRegex = /\S+/g; - let match: RegExpExecArray | null; - while ((match = wordRegex.exec(slice)) !== null) { - const raw = match[0]; - const norm = normalizeWordForMatch(raw); - if (!norm) continue; - const tokenStart = safeStart + match.index; - const tokenEnd = tokenStart + raw.length; - domTokens.push({ - node: textNode, - startOffset: tokenStart, - endOffset: tokenEnd, - norm, - }); + try { + const wordCfi = resolved.map.content.cfiFromRange(wordRange); + currentWordHighlightCfi.current = wordCfi; + renditionRef.current.annotations.add( + 'highlight', + wordCfi, + {}, + () => { }, + '', + { + fill: 'var(--accent)', + 'fill-opacity': '0.4', + 'mix-blend-mode': 'multiply', } - }; - - const nextTextNode = (node: Node | null): Text | null => { - let next = getNextTextNode(node, root); - while (next) { - if (next.nodeType === Node.TEXT_NODE) { - return next as Text; - } - next = getNextTextNode(next, root); - } - return null; - }; - - // Collect tokens within the sentence range - if (range.startContainer === range.endContainer && range.startContainer.nodeType === Node.TEXT_NODE) { - addTokensFromNode(range.startContainer as Text, range.startOffset, range.endOffset); - } else { - let current: Text | null = null; - - if (range.startContainer.nodeType === Node.TEXT_NODE) { - const startText = range.startContainer as Text; - const isEnd = range.endContainer === startText && range.endContainer.nodeType === Node.TEXT_NODE; - const endOffset = isEnd ? range.endOffset : (startText.textContent || '').length; - addTokensFromNode(startText, range.startOffset, endOffset); - if (isEnd) { - current = null; - } else { - current = nextTextNode(startText); - } - } else { - current = nextTextNode(range.startContainer); - } - - while (current) { - if (range.endContainer.nodeType === Node.TEXT_NODE && current === range.endContainer) { - addTokensFromNode(current, 0, range.endOffset); - break; - } else { - addTokensFromNode(current, 0, (current.textContent || '').length); - } - current = nextTextNode(current); - } - } - - if (!domTokens.length) { - return; - } - - const domFiltered: Array<{ tokenIndex: number; norm: string }> = []; - for (let i = 0; i < domTokens.length; i++) { - const norm = domTokens[i].norm; - if (!norm) continue; - domFiltered.push({ tokenIndex: i, norm }); - } - - const ttsFiltered: Array<{ wordIndex: number; norm: string }> = []; - for (let i = 0; i < words.length; i++) { - const norm = normalizeWordForMatch(words[i].text); - if (!norm) continue; - ttsFiltered.push({ wordIndex: i, norm }); - } - - const wordToToken = new Array(words.length).fill(-1); - const m = domFiltered.length; - const n = ttsFiltered.length; - - if (m && n) { - const dp: number[][] = Array.from({ length: m + 1 }, () => - new Array(n + 1).fill(Number.POSITIVE_INFINITY) - ); - const bt: number[][] = Array.from({ length: m + 1 }, () => - new Array(n + 1).fill(0) - ); // 0=diag,1=up,2=left - - dp[0][0] = 0; - const GAP_COST = 0.7; - - for (let i = 0; i <= m; i++) { - for (let j = 0; j <= n; j++) { - if (i > 0 && j > 0) { - const a = domFiltered[i - 1].norm; - const b = ttsFiltered[j - 1].norm; - const sim = cmp.compare(a, b); - const subCost = 1 - sim; - const cand = dp[i - 1][j - 1] + subCost; - if (cand < dp[i][j]) { - dp[i][j] = cand; - bt[i][j] = 0; - } - } - if (i > 0) { - const cand = dp[i - 1][j] + GAP_COST; - if (cand < dp[i][j]) { - dp[i][j] = cand; - bt[i][j] = 1; - } - } - if (j > 0) { - const cand = dp[i][j - 1] + GAP_COST; - if (cand < dp[i][j]) { - dp[i][j] = cand; - bt[i][j] = 2; - } - } - } - } - - let i = m; - let j = n; - while (i > 0 || j > 0) { - const move = bt[i][j]; - if (i > 0 && j > 0 && move === 0) { - const domIdx = domFiltered[i - 1].tokenIndex; - const ttsIdx = ttsFiltered[j - 1].wordIndex; - if (wordToToken[ttsIdx] === -1) { - wordToToken[ttsIdx] = domIdx; - } - i -= 1; - j -= 1; - } else if (i > 0 && (move === 1 || j === 0)) { - i -= 1; - } else if (j > 0 && (move === 2 || i === 0)) { - j -= 1; - } else { - break; - } - } - - // Propagate nearest known mapping to fill gaps - let lastSeen = -1; - for (let k = 0; k < wordToToken.length; k++) { - if (wordToToken[k] !== -1) { - lastSeen = wordToToken[k]; - } else if (lastSeen !== -1) { - wordToToken[k] = lastSeen; - } - } - let nextSeen = -1; - for (let k = wordToToken.length - 1; k >= 0; k--) { - if (wordToToken[k] !== -1) { - nextSeen = wordToToken[k]; - } else if (nextSeen !== -1) { - wordToToken[k] = nextSeen; - } - } - } - - const mappedIndex = - wordIndex < wordToToken.length ? wordToToken[wordIndex] : -1; - if (mappedIndex === -1) { - return; - } - - const token = domTokens[mappedIndex]; - const doc = token.node.ownerDocument || (range.commonAncestorContainer as Document); - const wordRange = doc.createRange(); - wordRange.setStart(token.node, token.startOffset); - wordRange.setEnd(token.node, token.endOffset); - - try { - const wordCfi = content.cfiFromRange(wordRange); - currentWordHighlightCfi.current = wordCfi; - renditionRef.current.annotations.add( - 'highlight', - wordCfi, - {}, - () => { }, - '', - { - fill: 'var(--accent)', - 'fill-opacity': '0.4', - 'mix-blend-mode': 'multiply', - } - ); - } catch (error) { - console.error('Error highlighting EPUB word:', error); - } - - break; + ); + } catch (error) { + console.error('Error highlighting EPUB word:', error); } }, [epubHighlightEnabled, clearWordHighlights]); @@ -1050,7 +1087,7 @@ export function EPUBProvider({ children }: { children: ReactNode }) { handleLocationChanged, setRendition, isAudioCombining, - highlightPattern, + highlightSegment, clearHighlights, highlightWordIndex, clearWordHighlights, @@ -1070,7 +1107,7 @@ export function EPUBProvider({ children }: { children: ReactNode }) { handleLocationChanged, setRendition, isAudioCombining, - highlightPattern, + highlightSegment, clearHighlights, highlightWordIndex, clearWordHighlights, diff --git a/src/contexts/TTSContext.tsx b/src/contexts/TTSContext.tsx index 875fcf5..3c01d90 100644 --- a/src/contexts/TTSContext.tsx +++ b/src/contexts/TTSContext.tsx @@ -36,15 +36,25 @@ import { useAudioContext } from '@/hooks/audio/useAudioContext'; import { getLastDocumentLocation, setLastDocumentLocation } from '@/lib/client/dexie'; import { getDocumentProgress, scheduleDocumentProgressSync } from '@/lib/client/api/user-state'; import { withRetry, ensureTtsSegments } from '@/lib/client/api/audiobooks'; -import { preprocessSentenceForAudio, splitTextToTtsBlocks, splitTextToTtsBlocksEPUB } from '@/lib/shared/nlp'; -import { normalizeEpubLocationToken } from '@/lib/shared/tts-locator'; +import { preprocessSentenceForAudio } from '@/lib/shared/nlp'; +import { + planCanonicalTtsSegments, + type CanonicalTtsSegment, + type CanonicalTtsSourceUnit, +} from '@/lib/shared/tts-segment-plan'; +import { + completedEpubBoundarySegment, + resolveEpubBoundaryHandoffStartIndex, + resolveEpubReplaySuppressionAction, + type CompletedEpubBoundarySegment, +} from '@/lib/shared/tts-epub-handoff'; +import { normalizeEpubLocationToken, normalizeTtsLocationKey } from '@/lib/shared/tts-locator'; import { isKokoroModel } from '@/lib/shared/kokoro'; import { supportsNativeModelSpeed, supportsTtsInstructions } from '@/lib/shared/tts-provider-catalog'; import { useAuthRateLimit } from '@/contexts/AuthRateLimitContext'; import type { EpubRenderedLocationWalker, TTSLocation, - TTSSmartMergeResult, TTSPageTurnEstimate, TTSPlaybackState, TTSSentenceAlignment, @@ -107,6 +117,7 @@ interface TTSContextType extends TTSPlaybackState { interface SetTextOptions { shouldPause?: boolean; location?: TTSLocation; + previousLocation?: TTSLocation; nextLocation?: TTSLocation; nextText?: string; previousText?: string; @@ -124,13 +135,9 @@ type TTSPendingJumpTarget = { index: number; }; -type EpubLocationWalkItem = { - location: string; - text: string; -}; - type EpubLocationPreloadCandidate = { sentence: string; + segmentKey: string; segmentIndex: number; location: string; requestKey: string; @@ -151,13 +158,9 @@ type JumpResolution = | { kind: 'strict-resolved'; index: number } | { kind: 'fresh' }; -const CONTINUATION_LOOKAHEAD = 600; -const MAX_CONTINUATION_CARRY_CHARS = 220; -const MAX_CONTINUATION_CARRY_WORDS = 40; const LOOP_GUARD_MIN_INDEX = 2; const LOOP_GUARD_MIN_PROGRESS = 0.6; const AUDIO_CACHE_MAX_ITEMS = 25; -const SENTENCE_ENDING = /[.?!…]["'”’)\]]*\s*$/; const wordHighlightFeatureEnabled = process.env.NEXT_PUBLIC_ENABLE_WORD_HIGHLIGHT?.toLowerCase() !== 'false'; @@ -165,8 +168,7 @@ const wordHighlightFeatureEnabled = const SILENT_WAV_DATA_URI = 'data:audio/wav;base64,UklGRkQDAABXQVZFZm10IBAAAAABAAEAQB8AAIA+AAACABAAZGF0YSADAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=='; -const normalizeLocationKey = (location: TTSLocation) => - typeof location === 'number' ? `num:${location}` : `str:${location}`; +const normalizeLocationKey = normalizeTtsLocationKey; const normalizeBlockFingerprint = (text: string): string => { const normalized = preprocessSentenceForAudio(text) @@ -177,153 +179,6 @@ const normalizeBlockFingerprint = (text: string): string => { return normalized.slice(0, 200); }; -const isWhitespaceChar = (char: string) => /\s/.test(char); - -const skipWhitespace = (source: string, start: number) => { - let index = start; - while (index < source.length && isWhitespaceChar(source[index])) { - index++; - } - return index; -}; - -const matchNormalizedPrefixLength = (text: string, prefix: string): number | null => { - let textIndex = 0; - let prefixIndex = 0; - - while (prefixIndex < prefix.length) { - const prefixChar = prefix[prefixIndex]; - - if (isWhitespaceChar(prefixChar)) { - prefixIndex = skipWhitespace(prefix, prefixIndex); - textIndex = skipWhitespace(text, textIndex); - continue; - } - - if (textIndex >= text.length) { - return null; - } - - const textChar = text[textIndex]; - - if (textChar === prefixChar || textChar.toLowerCase() === prefixChar.toLowerCase()) { - textIndex++; - prefixIndex++; - continue; - } - - return null; - } - - return textIndex; -}; - -const needsSentenceContinuation = (text: string) => { - const trimmed = text.trim(); - if (!trimmed) { - return false; - } - return !SENTENCE_ENDING.test(trimmed); -}; - -const stripContinuationPrefix = (text: string, prefix: string) => { - if (!prefix) return { text, removed: false }; - - // Try literal match first since PDF text is normalized already - if (text.startsWith(prefix)) { - return { - text: text.slice(prefix.length).trimStart(), - removed: true, - }; - } - - const trimmedPrefix = prefix.trimStart(); - const trimmedText = text.trimStart(); - - if (trimmedText.startsWith(trimmedPrefix)) { - const offset = text.length - trimmedText.length; - return { - text: text.slice(offset + trimmedPrefix.length).trimStart(), - removed: true, - }; - } - - const matchedLength = matchNormalizedPrefixLength(text, prefix); - if (matchedLength !== null) { - return { - text: text.slice(matchedLength).trimStart(), - removed: true, - }; - } - - return { text, removed: false }; -}; - -const extractContinuationSlice = (nextText: string): TTSSmartMergeResult | null => { - if (!nextText?.trim()) { - return null; - } - - const snippet = nextText.trim().slice(0, CONTINUATION_LOOKAHEAD); - let boundaryIndex = -1; - - for (let i = 0; i < snippet.length; i++) { - const char = snippet[i]; - if (/[.?!…]/.test(char)) { - let j = i + 1; - while (j < snippet.length && /["'”’)\]]/.test(snippet[j])) { - j++; - } - while (j < snippet.length && /\s/.test(snippet[j])) { - j++; - } - boundaryIndex = j; - break; - } - } - - if (boundaryIndex === -1) { - return null; - } - - const rawSlice = snippet.slice(0, boundaryIndex); - const charsCappedSlice = rawSlice.slice(0, MAX_CONTINUATION_CARRY_CHARS); - const words = charsCappedSlice.trim().split(/\s+/).filter(Boolean); - const wordCapped = words.slice(0, MAX_CONTINUATION_CARRY_WORDS).join(' '); - const addition = wordCapped.trim(); - - if (!addition) { - return null; - } - - return { - text: addition, - carried: addition, - }; -}; - -const mergeContinuation = (text: string, nextText: string): TTSSmartMergeResult | null => { - if (!needsSentenceContinuation(text)) { - return null; - } - - const slice = extractContinuationSlice(nextText); - if (!slice) { - return null; - } - - const trimmed = text.trimEnd(); - const endsWithHyphen = trimmed.endsWith('-'); - const base = endsWithHyphen ? trimmed.slice(0, -1) : trimmed; - const joiner = endsWithHyphen ? '' : (base ? ' ' : ''); - const mergedText = `${base}${joiner}${slice.text}`.trim(); - - return { - text: mergedText, - carried: slice.carried, - }; -}; - const buildCacheKey = ( sentence: string, voice: string, @@ -348,11 +203,13 @@ const buildScopedSegmentCacheKey = ( speed: number, provider: string, model: string, + segmentKey?: string | null, ) => { return [ buildCacheKey(sentence, voice, speed, provider, model), - `locator=${buildLocatorRequestKey(locator)}`, - `segmentIndex=${segmentIndex}`, + `segmentKey=${segmentKey || ''}`, + `locator=${segmentKey ? '' : buildLocatorRequestKey(locator)}`, + `segmentIndex=${segmentKey ? '' : segmentIndex}`, ].join('|'); }; @@ -367,48 +224,10 @@ const buildSegmentRequestKey = ( locator: TTSSegmentLocator, segmentIndex: number, sentence: string, -): string => `${buildLocatorRequestKey(locator)}::${segmentIndex}::${sentence}`; - -const planEpubLocationPreloadCandidates = (input: { - locationItems: EpubLocationWalkItem[]; - sentenceLookahead: number; - maxBlockLength: number; - segmentManifestCache: Map; - preloadRequests: Map; - splitSentences: (text: string, options: { maxBlockLength: number }) => string[]; - buildCacheKey: (location: string, segmentIndex: number, sentence: string) => string; -}): EpubLocationPreloadCandidate[] => { - const candidates: EpubLocationPreloadCandidate[] = []; - const { sentenceLookahead, maxBlockLength } = input; - - for (const item of input.locationItems) { - if (!item?.location || !item?.text?.trim()) continue; - - const sentences = input.splitSentences(item.text, { maxBlockLength }); - for (let index = 0; index < Math.min(sentenceLookahead, sentences.length); index += 1) { - const sentence = sentences[index]; - candidates.push({ - sentence, - segmentIndex: index, - location: item.location, - requestKey: `str:${item.location}::${index}::${sentence}`, - cacheKey: input.buildCacheKey(item.location, index, sentence), - }); - } - } - - const unique: EpubLocationPreloadCandidate[] = []; - const seen = new Set(); - for (const candidate of candidates) { - if (seen.has(candidate.requestKey)) continue; - seen.add(candidate.requestKey); - if (input.segmentManifestCache.has(candidate.cacheKey)) continue; - if (input.preloadRequests.has(candidate.requestKey)) continue; - unique.push(candidate); - } - - return unique; -}; + segmentKey?: string | null, +): string => segmentKey + ? `${segmentKey}::${sentence}` + : `${buildLocatorRequestKey(locator)}::${segmentIndex}::${sentence}`; const resolveJumpIndex = (input: JumpResolutionInput): JumpResolution => { if (input.newSentenceCount <= 0) { @@ -435,16 +254,17 @@ const resolveJumpIndex = (input: JumpResolutionInput): JumpResolution => { return { kind: 'fresh' }; }; -const splitCanonicalSentencesForReader = ( - text: string, +const sourceKeyForLocation = (location: TTSLocation | undefined, fallback: TTSLocation): string => + normalizeLocationKey(location ?? fallback); + +const locatorForLocation = ( + location: TTSLocation, readerType: ReaderType, - maxBlockLength: number, -): string[] => { - const options = { maxBlockLength }; - if (readerType === 'epub') { - return splitTextToTtsBlocksEPUB(text, options); +): TTSSegmentLocator => { + if (typeof location === 'string') { + return { location, readerType }; } - return splitTextToTtsBlocks(text, options); + return { page: Math.max(1, Math.floor(Number(location || 1))), readerType }; }; // Create the context @@ -551,6 +371,7 @@ export function TTSProvider({ children }: { children: ReactNode }): ReactElement const [currDocPages, setCurrDocPages] = useState(); const [sentences, setSentences] = useState([]); + const [playbackSegments, setPlaybackSegments] = useState([]); const [currentIndex, setCurrentIndex] = useState(0); const [activeHowl, setActiveHowl] = useState(null); const [speed, setSpeed] = useState(voiceSpeed); @@ -584,13 +405,10 @@ export function TTSProvider({ children }: { children: ReactNode }): ReactElement const epubWalkInFlightRef = useRef>(new Set()); // Guard to coalesce rapid restarts and only resume the latest change const restartSeqRef = useRef(0); - // Track continuation slices for PDF/EPUB page transitions - const continuationCarryRef = useRef>(new Map()); // Preserve autoplay intent across location changes. Some browsers can emit pause // events while we stop/unload between pages, which momentarily flips `isPlaying` // false and can prevent automatic resume on the next page. const resumeAfterLocationChangeRef = useRef(false); - const epubContinuationRef = useRef(null); const pageTurnEstimateRef = useRef(null); const pageTurnTimeoutRef = useRef | null>(null); const pageFirstBlockFingerprintRef = useRef>(new Map()); @@ -602,8 +420,9 @@ export function TTSProvider({ children }: { children: ReactNode }): ReactElement const pauseEpochRef = useRef(0); const sentencesRef = useRef([]); const currentIndexRef = useRef(0); - const setTextRef = useRef<(text: string, options?: boolean | SetTextOptions) => void>(() => { }); - const prefetchedLocationTextRef = useRef>(new Map()); + const plannedSegmentsByLocationRef = useRef>(new Map()); + const currentSourceUnitRef = useRef(null); + const completedEpubBoundarySegmentRef = useRef(null); const pendingNextLocationRef = useRef(undefined); const audioUnlockAttemptRef = useRef(0); @@ -767,24 +586,6 @@ export function TTSProvider({ children }: { children: ReactNode }): ReactElement isPlayingRef.current = isPlaying; }, [isPlaying]); - /** - * Processes text into sentences using the shared NLP utility - * - * @param {string} text - The text to be processed - * @returns {Promise} Array of processed sentences - */ - const splitTextToTtsBlocksLocal = useCallback(async (text: string): Promise => { - if (text.length < 1) { - return []; - } - - return splitCanonicalSentencesForReader( - text, - isEPUB ? 'epub' : currentReaderType, - ttsSegmentMaxBlockLength, - ); - }, [isEPUB, currentReaderType, ttsSegmentMaxBlockLength]); - /** * Stops the current audio playback and clears the active Howl instance * @param {boolean} [clearPending=false] - Whether to clear pending requests @@ -888,6 +689,7 @@ export function TTSProvider({ children }: { children: ReactNode }): ReactElement if (shouldPause) setIsPlaying(false); setCurrentIndex(0); setSentences([]); + setPlaybackSegments([]); setCurrDocPage(location); }, [abortAudio, invalidatePlaybackRun]); @@ -924,9 +726,8 @@ export function TTSProvider({ children }: { children: ReactNode }): ReactElement progress >= LOOP_GUARD_MIN_PROGRESS ) { const targetLocation = currDocPageNumber + 1; - prefetchedLocationTextRef.current.delete(normalizeLocationKey(targetLocation)); + plannedSegmentsByLocationRef.current.delete(normalizeLocationKey(targetLocation)); pendingNextLocationRef.current = targetLocation; - continuationCarryRef.current.delete(normalizeLocationKey(targetLocation)); skipToLocation(targetLocation); return; } @@ -942,15 +743,16 @@ export function TTSProvider({ children }: { children: ReactNode }): ReactElement const targetLocation = pendingNextLocationRef.current; if (targetLocation !== undefined) { const bufferKey = normalizeLocationKey(targetLocation); - const prefetchedText = prefetchedLocationTextRef.current.get(bufferKey); - if (prefetchedText?.trim()) { - prefetchedLocationTextRef.current.delete(bufferKey); + const prefetchedSegments = plannedSegmentsByLocationRef.current.get(bufferKey); + if (prefetchedSegments?.length) { + plannedSegmentsByLocationRef.current.delete(bufferKey); pendingNextLocationRef.current = undefined; setCurrDocPage(targetLocation); - setTextRef.current(prefetchedText, { - shouldPause: false, - location: targetLocation, - }); + setPlaybackSegments(prefetchedSegments); + setSentences(prefetchedSegments.map((segment) => segment.text)); + setCurrentIndex(0); + setCurrentSentenceAlignment(undefined); + setCurrentWordIndex(null); // Ask the viewer to continue turning pages/sections; this may be deferred while hidden. locationChangeHandlerRef.current('next'); return; @@ -972,15 +774,16 @@ export function TTSProvider({ children }: { children: ReactNode }): ReactElement // prefetched text for the target page, keep speaking without waiting for viewer callbacks. if (movingForward && typeof document !== 'undefined' && document.hidden) { const bufferKey = normalizeLocationKey(targetLocation); - const prefetchedText = prefetchedLocationTextRef.current.get(bufferKey); - if (prefetchedText?.trim()) { - prefetchedLocationTextRef.current.delete(bufferKey); + const prefetchedSegments = plannedSegmentsByLocationRef.current.get(bufferKey); + if (prefetchedSegments?.length) { + plannedSegmentsByLocationRef.current.delete(bufferKey); pendingNextLocationRef.current = undefined; setCurrDocPage(targetLocation); - setTextRef.current(prefetchedText, { - shouldPause: false, - location: targetLocation, - }); + setPlaybackSegments(prefetchedSegments); + setSentences(prefetchedSegments.map((segment) => segment.text)); + setCurrentIndex(0); + setCurrentSentenceAlignment(undefined); + setCurrentWordIndex(null); return; } } @@ -1034,100 +837,91 @@ export function TTSProvider({ children }: { children: ReactNode }): ReactElement ? { shouldPause: options } : (options || {}); - let workingText = text; + const resolvedLocation = normalizedOptions.location !== undefined + ? normalizedOptions.location + : currDocPage; + const resolvedLocationKey = normalizeLocationKey(resolvedLocation); + const activeReaderType = isEPUB ? 'epub' : currentReaderType; + const currentSourceKey = sourceKeyForLocation(resolvedLocation, currDocPage); + const currentSource: CanonicalTtsSourceUnit = { + sourceKey: currentSourceKey, + text, + locator: locatorForLocation(resolvedLocation, activeReaderType), + }; - // Apply or clear sentence continuation logic based on config - let continuationCarried: string | undefined; - let strippedByPreviousContext = false; - if (smartSentenceSplitting) { - 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) { - epubContinuationRef.current = null; - } - } - - if (!isEPUB && normalizedOptions.location !== undefined) { - const key = normalizeLocationKey(normalizedOptions.location); - 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); - } - } - } - } - - if (normalizedOptions.nextText) { - const merged = mergeContinuation(workingText, normalizedOptions.nextText); - if (merged) { - workingText = merged.text; - continuationCarried = merged.carried; - } - } - - if (continuationCarried) { - if (isEPUB) { - epubContinuationRef.current = continuationCarried; - } else if (normalizedOptions.nextLocation !== undefined) { - continuationCarryRef.current.set( - normalizeLocationKey(normalizedOptions.nextLocation), - continuationCarried - ); - } - } - } else { - // When disabled, clear any stale continuation state - epubContinuationRef.current = null; - continuationCarryRef.current.clear(); - pageTurnEstimateRef.current = null; + const sourceUnits: CanonicalTtsSourceUnit[] = []; + if (smartSentenceSplitting && normalizedOptions.previousText?.trim()) { + const previousLocation = normalizedOptions.previousLocation; + sourceUnits.push({ + sourceKey: previousLocation !== undefined + ? sourceKeyForLocation(previousLocation, currDocPage) + : `previous:${currentSourceKey}`, + text: normalizedOptions.previousText, + locator: previousLocation !== undefined + ? locatorForLocation(previousLocation, activeReaderType) + : null, + }); } + sourceUnits.push(currentSource); - // Keep the next-location text around so background page turns can continue when - // viewer/location callbacks are throttled by the browser. - prefetchedLocationTextRef.current.clear(); + plannedSegmentsByLocationRef.current.clear(); pendingNextLocationRef.current = normalizedOptions.nextLocation; - const pendingPrefetches: Array<{ location: TTSLocation; text: string }> = []; + const pendingPrefetches: Array = []; 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), }); } 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), }); } } for (const item of pendingPrefetches) { - prefetchedLocationTextRef.current.set(normalizeLocationKey(item.location), item.text); + if (smartSentenceSplitting) { + sourceUnits.push(item); + } } - // Check for blank section after adjustments - if (handleBlankSection(workingText)) return; + const plan = planCanonicalTtsSegments(sourceUnits, { + readerType: activeReaderType, + maxBlockLength: ttsSegmentMaxBlockLength, + keyPrefix: `${documentId || 'document'}:${activeReaderType}:v1`, + }); + const currentSegments = smartSentenceSplitting + ? plan.segments.filter((segment) => segment.ownerSourceKey === currentSourceKey) + : planCanonicalTtsSegments([currentSource], { + readerType: activeReaderType, + maxBlockLength: ttsSegmentMaxBlockLength, + keyPrefix: `${documentId || 'document'}:${activeReaderType}:v1`, + }).segments; + const newSentences = currentSegments.map((segment) => segment.text); + + for (const item of pendingPrefetches) { + const planned = smartSentenceSplitting + ? plan.segments.filter((segment) => segment.ownerSourceKey === item.sourceKey) + : planCanonicalTtsSegments([item], { + readerType: activeReaderType, + maxBlockLength: ttsSegmentMaxBlockLength, + keyPrefix: `${documentId || 'document'}:${activeReaderType}:v1`, + }).segments; + if (planned.length > 0) { + plannedSegmentsByLocationRef.current.set(normalizeLocationKey(item.location), planned); + } + } + + currentSourceUnitRef.current = currentSource; + + if (handleBlankSection(newSentences.join(' '))) return; const shouldPause = normalizedOptions.shouldPause ?? false; const pauseEpochAtStart = pauseEpochRef.current; @@ -1143,113 +937,115 @@ export function TTSProvider({ children }: { children: ReactNode }): ReactElement abortAudio(true); // Clear pending requests since text is changing setIsProcessing(true); // Set processing state before text processing starts - splitTextToTtsBlocksLocal(workingText) - .then(newSentences => { - if (newSentences.length === 0) { - console.warn('No sentences found in text'); - setIsProcessing(false); - return; - } - - if (!isEPUB && typeof normalizedOptions.location === 'number') { - const firstFingerprint = normalizeBlockFingerprint(newSentences[0] || ''); - if (firstFingerprint) { - pageFirstBlockFingerprintRef.current.set( - normalizeLocationKey(normalizedOptions.location), - firstFingerprint - ); - } - } - - // Set all state updates in a predictable order - setSentences(newSentences); - - const resolvedLocation = normalizedOptions.location !== undefined - ? normalizedOptions.location - : currDocPage; - const resolvedLocationKey = normalizeLocationKey(resolvedLocation); - const resolution = resolveJumpIndex({ - isEPUB, - newSentenceCount: newSentences.length, - resolvedLocationKey, - pendingEpubJump: pendingEpubJumpRef.current, - currentEpubEpoch: epubJumpEpochRef.current, - pendingStrictJump: pendingJumpTargetRef.current, - }); - if (resolution.kind === 'epub-resolved') { - setCurrentIndex(resolution.index); - pendingEpubJumpRef.current = null; - pendingJumpTargetRef.current = null; - } else if (resolution.kind === 'strict-resolved') { - setCurrentIndex(resolution.index); - pendingJumpTargetRef.current = null; - } else { - if (isEPUB) { - clearPendingEpubJump(); - } - setCurrentIndex(0); - } - - // Reset alignment state whenever the text block changes - sentenceAlignmentCacheRef.current.clear(); - setCurrentSentenceAlignment(undefined); - setCurrentWordIndex(null); - - // Compute auto page-turn estimate for PDFs when we have a continuation - if (smartSentenceSplitting && !isEPUB && continuationCarried && normalizedOptions.nextLocation !== undefined) { - const continuationNormalized = preprocessSentenceForAudio(continuationCarried); - if (continuationNormalized) { - let bestEstimate: TTSPageTurnEstimate | null = null; - - newSentences.forEach((sentence, index) => { - const normalizedSentence = preprocessSentenceForAudio(sentence); - if (!normalizedSentence) return; - - if (!normalizedSentence.toLowerCase().endsWith(continuationNormalized.toLowerCase())) return; - - const totalLength = normalizedSentence.length; - const continuationLength = continuationNormalized.length; - if (totalLength <= continuationLength) return; - - const baseLength = totalLength - continuationLength; - const fraction = baseLength / totalLength; - if (fraction <= 0 || fraction >= 1) return; - - bestEstimate = { - location: normalizedOptions.nextLocation!, - sentenceIndex: index, - fraction, - }; - }); - - pageTurnEstimateRef.current = bestEstimate; - } else { - pageTurnEstimateRef.current = null; - } - } else { - pageTurnEstimateRef.current = null; - } - + try { + if (newSentences.length === 0) { + console.warn('No sentences found in text'); setIsProcessing(false); + return; + } - // Restore playback state if needed - // Respect explicit pauses that happened while sentence splitting was in flight. - if (shouldResumePlayback && pauseEpochRef.current === pauseEpochAtStart) { - setIsPlaying(true); + if (!isEPUB && typeof resolvedLocation === 'number') { + const firstFingerprint = normalizeBlockFingerprint(newSentences[0] || ''); + if (firstFingerprint) { + pageFirstBlockFingerprintRef.current.set( + normalizeLocationKey(resolvedLocation), + firstFingerprint + ); } - }) - .catch(error => { - console.warn('Error processing text:', error); - setIsProcessing(false); - toast.error('Failed to process text', { - duration: 3000, - }); + } + + setPlaybackSegments(currentSegments); + setSentences(newSentences); + + const resolution = resolveJumpIndex({ + isEPUB, + newSentenceCount: newSentences.length, + resolvedLocationKey, + pendingEpubJump: pendingEpubJumpRef.current, + currentEpubEpoch: epubJumpEpochRef.current, + pendingStrictJump: pendingJumpTargetRef.current, }); - }, [isPlaying, handleBlankSection, abortAudio, splitTextToTtsBlocksLocal, isEPUB, smartSentenceSplitting, invalidatePlaybackRun, currDocPage, clearPendingEpubJump]); + let startIndex = 0; + if (resolution.kind === 'epub-resolved') { + startIndex = resolution.index; + setCurrentIndex(startIndex); + pendingEpubJumpRef.current = null; + pendingJumpTargetRef.current = null; + } else if (resolution.kind === 'strict-resolved') { + startIndex = resolution.index; + setCurrentIndex(startIndex); + pendingJumpTargetRef.current = null; + } else { + if (isEPUB) { + clearPendingEpubJump(); + } + startIndex = isEPUB && shouldResumePlayback + ? resolveEpubBoundaryHandoffStartIndex(currentSegments, completedEpubBoundarySegmentRef.current) + : 0; + if (startIndex > 0) { + completedEpubBoundarySegmentRef.current = null; + } + setCurrentIndex(startIndex); + } - useEffect(() => { - setTextRef.current = setText; - }, [setText]); + sentenceAlignmentCacheRef.current.clear(); + setCurrentSentenceAlignment(undefined); + setCurrentWordIndex(null); + + if (smartSentenceSplitting && !isEPUB && normalizedOptions.nextLocation !== undefined) { + const spanningIndex = currentSegments.findIndex((segment) => + segment.spansSourceBoundary + && segment.startAnchor.sourceKey === currentSourceKey + && segment.endAnchor.sourceKey !== currentSourceKey + ); + const spanningSegment = spanningIndex >= 0 ? currentSegments[spanningIndex] : null; + const currentTextLength = preprocessSentenceForAudio(text).length; + const totalLength = spanningSegment ? preprocessSentenceForAudio(spanningSegment.text).length : 0; + const baseLength = spanningSegment + ? Math.max(0, currentTextLength - spanningSegment.startAnchor.offset) + : 0; + const fraction = totalLength > 0 ? baseLength / totalLength : 0; + pageTurnEstimateRef.current = spanningSegment && fraction > 0 && fraction < 1 + ? { + location: normalizedOptions.nextLocation, + sentenceIndex: spanningIndex, + fraction, + } + : null; + } else { + pageTurnEstimateRef.current = null; + } + + setIsProcessing(false); + + if (shouldResumePlayback && startIndex >= newSentences.length) { + setIsPlaying(false); + return; + } + + if (shouldResumePlayback && pauseEpochRef.current === pauseEpochAtStart) { + setIsPlaying(true); + } + } catch (error) { + console.warn('Error processing text:', error); + setIsProcessing(false); + toast.error('Failed to process text', { + duration: 3000, + }); + } + }, [ + isPlaying, + handleBlankSection, + abortAudio, + isEPUB, + currentReaderType, + smartSentenceSplitting, + invalidatePlaybackRun, + currDocPage, + documentId, + ttsSegmentMaxBlockLength, + clearPendingEpubJump, + ]); /** * Toggles the playback state between playing and paused @@ -1414,6 +1210,7 @@ export function TTSProvider({ children }: { children: ReactNode }): ReactElement sentenceIndex: number, preload = false, locatorOverride?: TTSSegmentLocator, + segmentKey?: string | null, ): Promise => { const alignmentEnabledForCurrentDoc = wordHighlightFeatureEnabled && @@ -1430,6 +1227,7 @@ export function TTSProvider({ children }: { children: ReactNode }): ReactElement effectiveNativeSpeed, configTTSProvider, ttsModel, + segmentKey, ); const cachedManifest = segmentManifestCacheRef.current.get(audioCacheKey); @@ -1487,6 +1285,7 @@ export function TTSProvider({ children }: { children: ReactNode }): ReactElement segments: [ { segmentIndex: sentenceIndex, + ...(segmentKey ? { segmentKey } : {}), text: sentence, locator, }, @@ -1598,10 +1397,13 @@ export function TTSProvider({ children }: { children: ReactNode }): ReactElement sentenceIndex: number, preload = false, locatorOverride?: TTSSegmentLocator, + segmentKey?: string | null, ): Promise => { if (!audioContext) throw new Error('Audio context not initialized'); - const locatorKey = locatorOverride ? buildLocatorRequestKey(locatorOverride) : normalizeLocationKey(currDocPage); - const requestKey = `${locatorKey}::${sentenceIndex}::${sentence}`; + const locator = locatorOverride || (isEPUB + ? { location: String(currDocPage), readerType: currentReaderType } + : { page: Number(currDocPageNumber || 1), readerType: currentReaderType }); + const requestKey = buildSegmentRequestKey(locator, sentenceIndex, sentence, segmentKey); // Check if there's a pending preload request for this sentence const pendingRequest = preloadRequests.current.get(requestKey); @@ -1620,7 +1422,7 @@ export function TTSProvider({ children }: { children: ReactNode }): ReactElement // Create the audio processing promise const processPromise = (async () => { try { - const source = await getSegmentPlaybackSource(sentence, sentenceIndex, preload, locatorOverride); + const source = await getSegmentPlaybackSource(sentence, sentenceIndex, preload, locatorOverride, segmentKey); return source || null; } catch (error) { setIsProcessing(false); @@ -1642,14 +1444,20 @@ export function TTSProvider({ children }: { children: ReactNode }): ReactElement } return processPromise; - }, [audioContext, getSegmentPlaybackSource, currDocPage]); + }, [audioContext, getSegmentPlaybackSource, currDocPage, currDocPageNumber, currentReaderType, isEPUB]); /** * Plays the current sentence with Howl * * @param {string} sentence - The sentence to play */ - const playSentenceWithHowl = useCallback(async (sentence: string, sentenceIndex: number, runId: number) => { + const playSentenceWithHowl = useCallback(async ( + sentence: string, + sentenceIndex: number, + runId: number, + segmentKey?: string | null, + playbackSegment?: CanonicalTtsSegment | null, + ) => { if (!sentence) { playbackInFlightRef.current = false; setIsProcessing(false); @@ -1665,7 +1473,7 @@ export function TTSProvider({ children }: { children: ReactNode }): ReactElement ): Promise => { if (runId !== playbackRunIdRef.current) return null; let playErrorAttempts = 0; - const playbackSource = await processSentence(sentence, sentenceIndex); + const playbackSource = await processSentence(sentence, sentenceIndex, false, undefined, segmentKey); if (runId !== playbackRunIdRef.current) return null; if (!playbackSource) { // Graceful exit for rate limit / abort / intentionally skipped sentence @@ -1916,6 +1724,9 @@ export function TTSProvider({ children }: { children: ReactNode }): ReactElement clearTimeout(pageTurnTimeoutRef.current); pageTurnTimeoutRef.current = null; } + if (isEPUB) { + completedEpubBoundarySegmentRef.current = completedEpubBoundarySegment(playbackSegment); + } if (isPlaying) { advance(); } @@ -1974,6 +1785,7 @@ export function TTSProvider({ children }: { children: ReactNode }): ReactElement activeHowl, processSentence, audioSpeed, + isEPUB, isAutoplayBlockedError, applyPlaybackRateToHowl, startRateWatchdog, @@ -1982,10 +1794,37 @@ export function TTSProvider({ children }: { children: ReactNode }): ReactElement const playAudio = useCallback(async () => { const runId = playbackRunIdRef.current; - const sentence = sentences[currentIndex]; - const activeLocator: TTSSegmentLocator = isEPUB - ? { location: String(currDocPage), readerType: currentReaderType } - : { page: Number(currDocPageNumber || 1), readerType: currentReaderType }; + const playbackSegment = playbackSegments[currentIndex]; + const sentence = playbackSegment?.text ?? sentences[currentIndex]; + if (isEPUB && playbackSegment && completedEpubBoundarySegmentRef.current) { + const suppression = resolveEpubReplaySuppressionAction( + playbackSegments, + currentIndex, + completedEpubBoundarySegmentRef.current, + ); + if (suppression.kind === 'skip-to-index') { + playbackInFlightRef.current = false; + setCurrentSentenceAlignment(undefined); + setCurrentWordIndex(null); + completedEpubBoundarySegmentRef.current = null; + setCurrentIndex(suppression.index); + return; + } + if (suppression.kind === 'pause') { + playbackInFlightRef.current = false; + setCurrentSentenceAlignment(undefined); + setCurrentWordIndex(null); + completedEpubBoundarySegmentRef.current = null; + setIsProcessing(false); + setIsPlaying(false); + return; + } + completedEpubBoundarySegmentRef.current = null; + } + const activeLocator: TTSSegmentLocator = playbackSegment?.ownerLocator + ?? (isEPUB + ? { location: String(currDocPage), readerType: currentReaderType } + : { page: Number(currDocPageNumber || 1), readerType: currentReaderType }); const alignmentKey = buildScopedSegmentCacheKey( activeLocator, currentIndex, @@ -1994,6 +1833,7 @@ export function TTSProvider({ children }: { children: ReactNode }): ReactElement effectiveNativeSpeed, configTTSProvider, ttsModel, + playbackSegment?.key, ); const cachedAlignment = sentenceAlignmentCacheRef.current.get(alignmentKey); if (cachedAlignment) { @@ -2004,7 +1844,7 @@ export function TTSProvider({ children }: { children: ReactNode }): ReactElement setCurrentWordIndex(null); } - const howl = await playSentenceWithHowl(sentence, currentIndex, runId); + const howl = await playSentenceWithHowl(sentence, currentIndex, runId, playbackSegment?.key, playbackSegment); if (runId !== playbackRunIdRef.current) { if (howl) { try { howl.unload(); } catch {} @@ -2020,6 +1860,7 @@ export function TTSProvider({ children }: { children: ReactNode }): ReactElement } }, [ sentences, + playbackSegments, currentIndex, playSentenceWithHowl, voice, @@ -2107,11 +1948,13 @@ export function TTSProvider({ children }: { children: ReactNode }): ReactElement const preloadFromOffset = (offset: number) => { if (offset > sentenceLookahead) return; const sentenceIndex = currentIndex + offset; - const nextSentence = sentences[sentenceIndex]; + const nextSegment = playbackSegments[sentenceIndex]; + const nextSentence = nextSegment?.text ?? sentences[sentenceIndex]; if (!nextSentence) return; - const currentLocator: TTSSegmentLocator = { location: String(currDocPage), readerType: currentReaderType }; - const requestKey = buildSegmentRequestKey(currentLocator, sentenceIndex, nextSentence); + const currentLocator: TTSSegmentLocator = + nextSegment?.ownerLocator ?? { location: String(currDocPage), readerType: currentReaderType }; + const requestKey = buildSegmentRequestKey(currentLocator, sentenceIndex, nextSentence, nextSegment?.key); const cacheKey = buildScopedSegmentCacheKey( currentLocator, sentenceIndex, @@ -2120,6 +1963,7 @@ export function TTSProvider({ children }: { children: ReactNode }): ReactElement effectiveNativeSpeed, configTTSProvider, ttsModel, + nextSegment?.key, ); if (segmentManifestCacheRef.current.has(cacheKey)) { preloadFromOffset(offset + 1); @@ -2133,7 +1977,7 @@ export function TTSProvider({ children }: { children: ReactNode }): ReactElement return; } - void processSentence(nextSentence, sentenceIndex, true, currentLocator) + void processSentence(nextSentence, sentenceIndex, true, currentLocator, nextSegment?.key) .catch((error) => { const status = typeof error === 'object' && error !== null && 'status' in error ? ((error as { status?: unknown }).status as number | undefined) @@ -2195,51 +2039,61 @@ export function TTSProvider({ children }: { children: ReactNode }): ReactElement const upcomingLocationItems = filteredLocationItems.slice(0, targetDepth); if (!upcomingLocationItems.length) return; - const normalizedLocationItems = upcomingLocationItems.map((item) => ({ - location: item.location, + const sourceUnits: CanonicalTtsSourceUnit[] = []; + if (smartSentenceSplitting && currentSourceUnitRef.current) { + sourceUnits.push(currentSourceUnitRef.current); + } + sourceUnits.push(...upcomingLocationItems.map((item) => ({ + sourceKey: sourceKeyForLocation(item.location, currDocPage), text: item.text, - })); - if (smartSentenceSplitting) { - const carryByIndex = new Map(); - if (epubContinuationRef.current) { - carryByIndex.set(0, epubContinuationRef.current); - } - for (let i = 0; i < normalizedLocationItems.length; i += 1) { - const current = normalizedLocationItems[i]; - const carry = carryByIndex.get(i); - if (carry) { - current.text = stripContinuationPrefix(current.text, carry).text; - } - const next = normalizedLocationItems[i + 1]; - if (!next?.text?.trim()) continue; - const merged = mergeContinuation(current.text, next.text); - if (merged?.carried) { - carryByIndex.set(i + 1, merged.carried); - } + locator: { location: item.location, readerType: 'epub' as const }, + }))); + + const plan = planCanonicalTtsSegments(sourceUnits, { + readerType: 'epub', + maxBlockLength: ttsSegmentMaxBlockLength, + keyPrefix: `${documentId || 'document'}:epub:v1`, + }); + const uniqueCandidates: EpubLocationPreloadCandidate[] = []; + const seenCandidates = new Set(); + for (const item of upcomingLocationItems) { + const sourceKey = sourceKeyForLocation(item.location, currDocPage); + const planned = plan.segments + .filter((segment) => segment.ownerSourceKey === sourceKey) + .slice(0, sentenceLookahead); + for (let index = 0; index < planned.length; index += 1) { + const segment = planned[index]; + const locator = segment.ownerLocator ?? { location: item.location, readerType: 'epub' as const }; + const requestKey = buildSegmentRequestKey(locator, index, segment.text, segment.key); + const cacheKey = buildScopedSegmentCacheKey( + locator, + index, + segment.text, + voice, + effectiveNativeSpeed, + configTTSProvider, + ttsModel, + segment.key, + ); + if (seenCandidates.has(requestKey)) continue; + seenCandidates.add(requestKey); + if (segmentManifestCacheRef.current.has(cacheKey)) continue; + if (preloadRequests.current.has(requestKey)) continue; + uniqueCandidates.push({ + sentence: segment.text, + segmentKey: segment.key, + segmentIndex: index, + location: item.location, + requestKey, + cacheKey, + }); } } - - const uniqueCandidates = planEpubLocationPreloadCandidates({ - locationItems: normalizedLocationItems, - sentenceLookahead, - maxBlockLength: ttsSegmentMaxBlockLength, - segmentManifestCache: segmentManifestCacheRef.current, - preloadRequests: preloadRequests.current, - splitSentences: splitTextToTtsBlocksEPUB, - buildCacheKey: (location, segmentIndex, sentence) => buildScopedSegmentCacheKey( - { location, readerType: 'epub' }, - segmentIndex, - sentence, - voice, - effectiveNativeSpeed, - configTTSProvider, - ttsModel, - ), - }); if (uniqueCandidates.length === 0) return; const payload = uniqueCandidates.map((candidate) => ({ segmentIndex: candidate.segmentIndex, + segmentKey: candidate.segmentKey, text: candidate.sentence, locator: { location: candidate.location, readerType: 'epub' as const }, })); @@ -2268,14 +2122,11 @@ export function TTSProvider({ children }: { children: ReactNode }): ReactElement for (const segment of ensured.segments) { if (segment.status !== 'completed' || !segment.audioPresignUrl || !segment.audioFallbackUrl) continue; if (!segment.locator) continue; - segmentLookup.set( - `${buildLocatorRequestKey(segment.locator)}::${segment.segmentIndex}`, - segment, - ); + segmentLookup.set(segment.segmentKey || `${buildLocatorRequestKey(segment.locator)}::${segment.segmentIndex}`, segment); } for (const candidate of uniqueCandidates) { - const segment = segmentLookup.get(`str:${candidate.location}::${candidate.segmentIndex}`); + const segment = segmentLookup.get(candidate.segmentKey); if (!segment) continue; setSegmentManifestCache(candidate.cacheKey, segment); if (alignmentEnabledForCurrentDoc && segment.alignment) { @@ -2331,9 +2182,9 @@ export function TTSProvider({ children }: { children: ReactNode }): ReactElement wordHighlightFeatureEnabled && ((!isEPUB && pdfHighlightEnabled && pdfWordHighlightEnabled) || (isEPUB && epubHighlightEnabled && epubWordHighlightEnabled)); - const splitOptions = { maxBlockLength: ttsSegmentMaxBlockLength }; const candidates: Array<{ sentence: string; + segmentKey?: string | null; segmentIndex: number; locator: TTSSegmentLocator; requestKey: string; @@ -2346,26 +2197,27 @@ export function TTSProvider({ children }: { children: ReactNode }): ReactElement for (let offset = 1; offset <= sentenceLookahead; offset += 1) { const sentenceIndex = currentIndex + offset; - const sentence = sentences[sentenceIndex]; + const plannedSegment = playbackSegments[sentenceIndex]; + const sentence = plannedSegment?.text ?? sentences[sentenceIndex]; if (!sentence) break; + const locator = plannedSegment?.ownerLocator ?? currentLocator; const cacheKey = buildScopedSegmentCacheKey( - currentLocator, + locator, sentenceIndex, sentence, voice, effectiveNativeSpeed, configTTSProvider, ttsModel, + plannedSegment?.key, ); - const requestKey = `${buildLocatorRequestKey(currentLocator)}::${sentenceIndex}::${sentence}`; - candidates.push({ sentence, segmentIndex: sentenceIndex, locator: currentLocator, requestKey, cacheKey }); + const requestKey = buildSegmentRequestKey(locator, sentenceIndex, sentence, plannedSegment?.key); + candidates.push({ sentence, segmentKey: plannedSegment?.key, segmentIndex: sentenceIndex, locator, requestKey, cacheKey }); } - const prefetched = Array.from(prefetchedLocationTextRef.current.entries()).slice(0, maxDepth); - const carryByLocation = new Map(continuationCarryRef.current); - for (let prefetchedIndex = 0; prefetchedIndex < prefetched.length; prefetchedIndex += 1) { - const [locationKey, text] = prefetched[prefetchedIndex]; - if (!text?.trim()) continue; + const prefetched = Array.from(plannedSegmentsByLocationRef.current.entries()).slice(0, maxDepth); + for (const [locationKey, planned] of prefetched) { + if (!planned.length) continue; const location = locationKey.startsWith('num:') ? Number(locationKey.slice(4)) : locationKey.startsWith('str:') @@ -2376,38 +2228,22 @@ export function TTSProvider({ children }: { children: ReactNode }): ReactElement const locator: TTSSegmentLocator = typeof location === 'string' ? { location, readerType: currentReaderType } : { page: Number(location || 1), readerType: currentReaderType }; - let normalizedUpcomingText = text; - if (smartSentenceSplitting) { - const carriedPrefix = carryByLocation.get(locationKey); - if (carriedPrefix) { - const stripped = stripContinuationPrefix(normalizedUpcomingText, carriedPrefix); - normalizedUpcomingText = stripped.text; - } - const nextPrefetched = prefetched[prefetchedIndex + 1]; - if (nextPrefetched && nextPrefetched[1]?.trim()) { - const merged = mergeContinuation(normalizedUpcomingText, nextPrefetched[1]); - if (merged?.carried) { - carryByLocation.set(nextPrefetched[0], merged.carried); - } - } - } - const upcomingSentences = isEPUB - ? splitTextToTtsBlocksEPUB(normalizedUpcomingText, splitOptions) - : splitTextToTtsBlocks(normalizedUpcomingText, splitOptions); - - for (let index = 0; index < Math.min(sentenceLookahead, upcomingSentences.length); index += 1) { - const sentence = upcomingSentences[index]; + for (let index = 0; index < Math.min(sentenceLookahead, planned.length); index += 1) { + const segment = planned[index]; + const sentence = segment.text; + const segmentLocator = segment.ownerLocator ?? locator; const cacheKey = buildScopedSegmentCacheKey( - locator, + segmentLocator, index, sentence, voice, effectiveNativeSpeed, configTTSProvider, ttsModel, + segment.key, ); - const requestKey = `${buildLocatorRequestKey(locator)}::${index}::${sentence}`; - candidates.push({ sentence, segmentIndex: index, locator, requestKey, cacheKey }); + const requestKey = buildSegmentRequestKey(segmentLocator, index, sentence, segment.key); + candidates.push({ sentence, segmentKey: segment.key, segmentIndex: index, locator: segmentLocator, requestKey, cacheKey }); } } @@ -2440,6 +2276,7 @@ export function TTSProvider({ children }: { children: ReactNode }): ReactElement }; const payload = uniqueCandidates.map((candidate) => ({ segmentIndex: candidate.segmentIndex, + ...(candidate.segmentKey ? { segmentKey: candidate.segmentKey } : {}), text: candidate.sentence, locator: candidate.locator, })); @@ -2531,6 +2368,7 @@ export function TTSProvider({ children }: { children: ReactNode }): ReactElement currentReaderType, currentIndex, sentences, + playbackSegments, voice, effectiveNativeSpeed, configTTSProvider, @@ -2597,15 +2435,17 @@ export function TTSProvider({ children }: { children: ReactNode }): ReactElement invalidatePlaybackRun(); abortAudio(); playbackInFlightRef.current = false; - epubContinuationRef.current = null; pendingJumpTargetRef.current = null; clearPendingEpubJump(); bumpEpubPreloadGeneration(); - continuationCarryRef.current.clear(); + plannedSegmentsByLocationRef.current.clear(); + currentSourceUnitRef.current = null; + completedEpubBoundarySegmentRef.current = null; pageFirstBlockFingerprintRef.current.clear(); setIsPlaying(false); setCurrentIndex(0); setSentences([]); + setPlaybackSegments([]); setCurrDocPage(1); setCurrDocPages(undefined); setIsProcessing(false); @@ -2799,6 +2639,7 @@ export function TTSProvider({ children }: { children: ReactNode }): ReactElement isPlaying, isProcessing, currentSentence: sentences[currentIndex] || '', + currentSegment: playbackSegments[currentIndex] ?? null, sentences, currentSentenceIndex: currentIndex, currentSentenceAlignment, @@ -2829,6 +2670,7 @@ export function TTSProvider({ children }: { children: ReactNode }): ReactElement isPlaying, isProcessing, sentences, + playbackSegments, currentIndex, currDocPage, currDocPageNumber, diff --git a/src/lib/server/tts/segments-manifest.ts b/src/lib/server/tts/segments-manifest.ts index 64314d3..a422391 100644 --- a/src/lib/server/tts/segments-manifest.ts +++ b/src/lib/server/tts/segments-manifest.ts @@ -1,4 +1,4 @@ -import { locatorGroupKey } from '@/lib/shared/tts-locator'; +import { compareSegmentLocators, locatorGroupKey } from '@/lib/shared/tts-locator'; import type { TTSSegmentLocator, TTSSegmentVariant, @@ -39,13 +39,8 @@ export function compareManifestSegments( a: { locator: TTSSegmentLocator | null; segmentIndex: number; groupKey: string }, b: { locator: TTSSegmentLocator | null; segmentIndex: number; groupKey: string }, ): number { - const aPage = typeof a.locator?.page === 'number' ? a.locator.page : Number.MAX_SAFE_INTEGER; - const bPage = typeof b.locator?.page === 'number' ? b.locator.page : Number.MAX_SAFE_INTEGER; - if (aPage !== bPage) return aPage - bPage; - const aLoc = a.locator?.location || ''; - const bLoc = b.locator?.location || ''; - const byLocation = aLoc.localeCompare(bLoc); - if (byLocation !== 0) return byLocation; + const byLocator = compareSegmentLocators(a.locator, b.locator); + if (byLocator !== 0) return byLocator; if (a.segmentIndex !== b.segmentIndex) return a.segmentIndex - b.segmentIndex; return a.groupKey.localeCompare(b.groupKey); } diff --git a/src/lib/server/tts/segments.ts b/src/lib/server/tts/segments.ts index 9cefb59..480e5ce 100644 --- a/src/lib/server/tts/segments.ts +++ b/src/lib/server/tts/segments.ts @@ -96,6 +96,7 @@ export function buildTtsSegmentId(input: { documentVersion: number; settingsHash: string; segmentIndex: number; + segmentKey?: string | null; normalizedText: string; locatorFingerprint: string; }): string { @@ -103,9 +104,10 @@ export function buildTtsSegmentId(input: { d: input.documentId, v: input.documentVersion, s: input.settingsHash, - i: input.segmentIndex, + k: input.segmentKey || null, + i: input.segmentKey ? null : input.segmentIndex, t: input.normalizedText, - l: input.locatorFingerprint, + l: input.segmentKey ? null : input.locatorFingerprint, }); return createHash('sha256').update(canonical).digest('hex'); } diff --git a/src/lib/shared/epub-word-highlight.ts b/src/lib/shared/epub-word-highlight.ts new file mode 100644 index 0000000..a249b7e --- /dev/null +++ b/src/lib/shared/epub-word-highlight.ts @@ -0,0 +1,114 @@ +import type { CanonicalTtsSegment } from '@/lib/shared/tts-segment-plan'; +import type { TTSSentenceAlignment } from '@/types/tts'; + +export type EpubCanonicalWordToken = { + norm: string; + sourceStart: number; + sourceEnd: number; +}; + +export const normalizeWordForHighlight = (text: string): string => + text + .toLowerCase() + .replace(/[^a-z0-9]+/g, ''); + +export const tokenizeCanonicalSegment = (segment: CanonicalTtsSegment): EpubCanonicalWordToken[] => { + const tokens: EpubCanonicalWordToken[] = []; + const wordRegex = /\S+/g; + let match: RegExpExecArray | null; + + while ((match = wordRegex.exec(segment.text)) !== null) { + const raw = match[0]; + const leading = raw.match(/^[^A-Za-z0-9]*/)?.[0].length ?? 0; + const trailing = raw.match(/[^A-Za-z0-9]*$/)?.[0].length ?? 0; + const start = match.index + leading; + const end = match.index + raw.length - trailing; + if (end <= start) continue; + + const norm = normalizeWordForHighlight(raw.slice(leading, raw.length - trailing)); + if (!norm) continue; + + tokens.push({ + norm, + sourceStart: segment.startAnchor.offset + start, + sourceEnd: segment.startAnchor.offset + end, + }); + } + + return tokens; +}; + +export const buildMonotonicWordToTokenMap = ( + alignmentWords: TTSSentenceAlignment['words'], + segmentTokens: EpubCanonicalWordToken[], +): number[] => { + const alignmentTokens = alignmentWords.map((word) => normalizeWordForHighlight(word.text)); + const wordToToken = new Array(alignmentWords.length).fill(-1); + const m = alignmentTokens.length; + const n = segmentTokens.length; + if (!m || !n) return wordToToken; + + const dp: number[][] = Array.from({ length: m + 1 }, () => new Array(n + 1).fill(0)); + const bt: number[][] = Array.from({ length: m + 1 }, () => new Array(n + 1).fill(0)); + + for (let i = 1; i <= m; i += 1) { + for (let j = 1; j <= n; j += 1) { + let best = dp[i - 1][j - 1]; + let move = 0; + + const alignmentNorm = alignmentTokens[i - 1]; + const segmentNorm = segmentTokens[j - 1].norm; + if (alignmentNorm && alignmentNorm === segmentNorm) { + const positionPenalty = + m <= 1 || n <= 1 + ? 0 + : Math.abs((i - 1) / (m - 1) - (j - 1) / (n - 1)); + best = dp[i - 1][j - 1] + 10 - positionPenalty; + move = 1; + } + + if (dp[i - 1][j] > best) { + best = dp[i - 1][j]; + move = 2; + } + if (dp[i][j - 1] > best) { + best = dp[i][j - 1]; + move = 3; + } + + dp[i][j] = best; + bt[i][j] = move; + } + } + + let i = m; + let j = n; + while (i > 0 && j > 0) { + const move = bt[i][j]; + if (move === 1) { + wordToToken[i - 1] = j - 1; + i -= 1; + j -= 1; + } else if (move === 2) { + i -= 1; + } else if (move === 3) { + j -= 1; + } else { + i -= 1; + j -= 1; + } + } + + return wordToToken; +}; + +export const buildWordHighlightCacheKey = ( + segment: CanonicalTtsSegment, + alignment: TTSSentenceAlignment, +): string => + [ + segment.key, + segment.text.length, + alignment.words.length, + alignment.words.map((word) => normalizeWordForHighlight(word.text)).join('|'), + ].join('::'); diff --git a/src/lib/shared/tts-epub-handoff.ts b/src/lib/shared/tts-epub-handoff.ts new file mode 100644 index 0000000..9643b2b --- /dev/null +++ b/src/lib/shared/tts-epub-handoff.ts @@ -0,0 +1,96 @@ +import { preprocessSentenceForAudio } from '@/lib/shared/nlp'; +import type { CanonicalTtsSegment } from '@/lib/shared/tts-segment-plan'; + +export type CompletedEpubBoundarySegment = { + key: string; + fingerprint: string; + completedAt: number; +}; + +export type EpubReplaySuppressionAction = + | { kind: 'none' } + | { kind: 'skip-to-index'; index: number } + | { kind: 'pause' }; + +export const EPUB_BOUNDARY_HANDOFF_MAX_AGE_MS = 2 * 60 * 1000; + +export const fingerprintEpubBoundarySegment = (text: string): string => + preprocessSentenceForAudio(text) + .toLowerCase() + .replace(/\s+/g, ' ') + .trim(); + +export const completedEpubBoundarySegment = ( + segment: CanonicalTtsSegment | null | undefined, + now = Date.now(), +): CompletedEpubBoundarySegment | null => { + if (!segment?.spansSourceBoundary) return null; + const fingerprint = fingerprintEpubBoundarySegment(segment.text); + if (!fingerprint) return null; + return { + key: segment.key, + fingerprint, + completedAt: now, + }; +}; + +export const resolveEpubBoundaryHandoffStartIndex = ( + segments: CanonicalTtsSegment[], + completed: CompletedEpubBoundarySegment | null, + now = Date.now(), +): number => { + if (!completed) return 0; + if (now - completed.completedAt > EPUB_BOUNDARY_HANDOFF_MAX_AGE_MS) return 0; + + let index = 0; + while (index < segments.length) { + const segment = segments[index]; + if (segment.key === completed.key) { + index += 1; + continue; + } + if (fingerprintEpubBoundarySegment(segment.text) === completed.fingerprint) { + index += 1; + continue; + } + break; + } + + return index; +}; + +export const shouldSuppressCompletedEpubBoundaryReplay = ( + segment: CanonicalTtsSegment | null | undefined, + completed: CompletedEpubBoundarySegment | null, + now = Date.now(), +): boolean => { + if (!segment || !completed) return false; + if (now - completed.completedAt > EPUB_BOUNDARY_HANDOFF_MAX_AGE_MS) return false; + if (segment.key === completed.key) return true; + return fingerprintEpubBoundarySegment(segment.text) === completed.fingerprint; +}; + +export const resolveEpubReplaySuppressionAction = ( + segments: CanonicalTtsSegment[], + currentIndex: number, + completed: CompletedEpubBoundarySegment | null, + now = Date.now(), +): EpubReplaySuppressionAction => { + if (!shouldSuppressCompletedEpubBoundaryReplay(segments[currentIndex], completed, now)) { + return { kind: 'none' }; + } + + let nextIndex = currentIndex + 1; + while ( + nextIndex < segments.length + && shouldSuppressCompletedEpubBoundaryReplay(segments[nextIndex], completed, now) + ) { + nextIndex += 1; + } + + if (nextIndex < segments.length) { + return { kind: 'skip-to-index', index: nextIndex }; + } + + return { kind: 'pause' }; +}; diff --git a/src/lib/shared/tts-locator.ts b/src/lib/shared/tts-locator.ts index 9793122..4d874e1 100644 --- a/src/lib/shared/tts-locator.ts +++ b/src/lib/shared/tts-locator.ts @@ -1,4 +1,10 @@ import type { TTSSegmentLocator } from '@/types/client'; +import type { TTSLocation } from '@/types/tts'; + +const naturalLocationCollator = new Intl.Collator(undefined, { + numeric: true, + sensitivity: 'base', +}); export function normalizeEpubLocationToken(location: string): string { return location @@ -7,6 +13,10 @@ export function normalizeEpubLocationToken(location: string): string { .replace(/\s+/g, ''); } +export function normalizeTtsLocationKey(location: TTSLocation): string { + return typeof location === 'number' ? `num:${location}` : `str:${location}`; +} + export function locatorGroupKey(locator: TTSSegmentLocator | null): string { if (!locator) return 'none'; const page = typeof locator.page === 'number' && Number.isFinite(locator.page) @@ -16,3 +26,32 @@ export function locatorGroupKey(locator: TTSSegmentLocator | null): string { const readerType = locator.readerType || ''; return `p:${page}|l:${location}|r:${readerType}`; } + +export function compareLocationTokens(a: string, b: string): number { + return naturalLocationCollator.compare( + normalizeEpubLocationToken(a), + normalizeEpubLocationToken(b), + ); +} + +export function compareSegmentLocators( + a: TTSSegmentLocator | null, + b: TTSSegmentLocator | null, +): number { + const aPage = typeof a?.page === 'number' && Number.isFinite(a.page) + ? Math.floor(a.page) + : Number.MAX_SAFE_INTEGER; + const bPage = typeof b?.page === 'number' && Number.isFinite(b.page) + ? Math.floor(b.page) + : Number.MAX_SAFE_INTEGER; + if (aPage !== bPage) return aPage - bPage; + + const aLocation = typeof a?.location === 'string' ? a.location : ''; + const bLocation = typeof b?.location === 'string' ? b.location : ''; + const byLocation = compareLocationTokens(aLocation, bLocation); + if (byLocation !== 0) return byLocation; + + const aReaderType = a?.readerType || ''; + const bReaderType = b?.readerType || ''; + return aReaderType.localeCompare(bReaderType); +} diff --git a/src/lib/shared/tts-segment-plan.ts b/src/lib/shared/tts-segment-plan.ts new file mode 100644 index 0000000..d674544 --- /dev/null +++ b/src/lib/shared/tts-segment-plan.ts @@ -0,0 +1,369 @@ +import { preprocessSentenceForAudio, splitTextToTtsBlocks, splitTextToTtsBlocksEPUB } from '@/lib/shared/nlp'; +import type { TTSSegmentLocator } from '@/types/client'; +import type { ReaderType } from '@/types/user-state'; + +export const TTS_SEGMENT_PLAN_VERSION = 'tts-segment-plan-v1'; + +export interface CanonicalTtsSourceUnit { + sourceKey: string; + text: string; + locator?: TTSSegmentLocator | null; +} + +export interface CanonicalTtsAnchor { + sourceKey: string; + offset: number; +} + +export interface CanonicalTtsSegment { + key: string; + ordinal: number; + text: string; + ownerSourceKey: string; + ownerLocator: TTSSegmentLocator | null; + startAnchor: CanonicalTtsAnchor; + endAnchor: CanonicalTtsAnchor; + spansSourceBoundary: boolean; +} + +export interface CanonicalTtsSegmentPlan { + version: string; + readerType: ReaderType; + text: string; + segments: CanonicalTtsSegment[]; +} + +export interface CanonicalTtsSegmentPlanOptions { + readerType?: ReaderType; + maxBlockLength?: number; + keyPrefix?: string; +} + +interface PreparedSourceUnit { + sourceKey: string; + text: string; + locator: TTSSegmentLocator | null; + startOffset: number; + endOffset: number; +} + +interface NormalizedTextMap { + text: string; + rawByNormalizedIndex: number[]; +} + +const normalizeSourceText = (text: string): string => + preprocessSentenceForAudio(text) + .replace(/\s+/g, ' ') + .trim(); + +const normalizeSegmentIdentityText = (text: string): string => + preprocessSentenceForAudio(text) + .toLowerCase() + .replace(/\s+/g, ' ') + .trim(); + +const stableHash = (value: string): string => { + let hash = 0x811c9dc5; + for (let i = 0; i < value.length; i += 1) { + hash ^= value.charCodeAt(i); + hash = Math.imul(hash, 0x01000193) >>> 0; + } + return hash.toString(16).padStart(8, '0'); +}; + +const buildSegmentKey = (keyPrefix: string, text: string): string => + [ + keyPrefix, + stableHash(normalizeSegmentIdentityText(text)), + ].join(':'); + +const normalizeWithRawMap = (text: string): NormalizedTextMap => { + let normalized = ''; + const rawByNormalizedIndex: number[] = []; + let pendingWhitespaceIndex: number | null = null; + + const flushWhitespace = () => { + if (pendingWhitespaceIndex === null || normalized.length === 0 || normalized.endsWith(' ')) { + pendingWhitespaceIndex = null; + return; + } + normalized += ' '; + rawByNormalizedIndex.push(pendingWhitespaceIndex); + pendingWhitespaceIndex = null; + }; + + for (let rawIndex = 0; rawIndex < text.length; rawIndex += 1) { + const char = text[rawIndex]; + if (/\s/.test(char)) { + if (pendingWhitespaceIndex === null) pendingWhitespaceIndex = rawIndex; + continue; + } + + flushWhitespace(); + normalized += char; + rawByNormalizedIndex.push(rawIndex); + } + + if (normalized.endsWith(' ')) { + normalized = normalized.slice(0, -1); + rawByNormalizedIndex.pop(); + } + + return { text: normalized, rawByNormalizedIndex }; +}; + +const findFlexibleOffset = ( + haystack: string, + needle: string, + fromNormalizedIndex: number, +): { start: number; end: number; normalizedEnd: number } | null => { + const normalizedHaystack = normalizeWithRawMap(haystack); + const normalizedNeedle = normalizeWithRawMap(needle).text; + if (!normalizedNeedle) return null; + + const safeFrom = Math.max(0, Math.min(fromNormalizedIndex, normalizedHaystack.text.length)); + const normalizedStart = normalizedHaystack.text.indexOf(normalizedNeedle, safeFrom); + if (normalizedStart < 0) return null; + + const normalizedEnd = normalizedStart + normalizedNeedle.length; + const rawStart = normalizedHaystack.rawByNormalizedIndex[normalizedStart]; + const rawEndLast = normalizedHaystack.rawByNormalizedIndex[normalizedEnd - 1]; + if (rawStart === undefined || rawEndLast === undefined) return null; + + return { + start: rawStart, + end: rawEndLast + 1, + normalizedEnd, + }; +}; + +const findSourceForOffset = ( + sources: PreparedSourceUnit[], + offset: number, + bias: 'start' | 'end', +): PreparedSourceUnit | null => { + if (sources.length === 0) return null; + const clamped = Math.max(0, offset); + + const containing = sources.find((source) => + bias === 'start' + ? clamped >= source.startOffset && clamped < source.endOffset + : clamped > source.startOffset && clamped <= source.endOffset, + ); + if (containing) return containing; + + if (bias === 'start') { + return sources.find((source) => clamped < source.startOffset) ?? sources[sources.length - 1]; + } + + for (let i = sources.length - 1; i >= 0; i -= 1) { + if (clamped > sources[i].endOffset) return sources[i]; + } + return sources[0]; +}; + +const anchorForOffset = ( + source: PreparedSourceUnit, + offset: number, +): CanonicalTtsAnchor => ({ + sourceKey: source.sourceKey, + offset: Math.max(0, Math.min(offset - source.startOffset, source.text.length)), +}); + +export function planCanonicalTtsSegments( + sourceUnits: CanonicalTtsSourceUnit[], + options: CanonicalTtsSegmentPlanOptions = {}, +): CanonicalTtsSegmentPlan { + const readerType = options.readerType ?? 'pdf'; + const keyPrefix = options.keyPrefix ?? TTS_SEGMENT_PLAN_VERSION; + const preparedSources: PreparedSourceUnit[] = []; + const textParts: string[] = []; + let combinedLength = 0; + + for (const sourceUnit of sourceUnits) { + const text = normalizeSourceText(sourceUnit.text); + if (!text) continue; + + if (textParts.length > 0) { + textParts.push(' '); + combinedLength += 1; + } + + const startOffset = combinedLength; + textParts.push(text); + combinedLength += text.length; + preparedSources.push({ + sourceKey: sourceUnit.sourceKey, + text, + locator: sourceUnit.locator ?? null, + startOffset, + endOffset: combinedLength, + }); + } + + const canonicalText = textParts.join(''); + const splitOptions = { maxBlockLength: options.maxBlockLength }; + const blocks = readerType === 'epub' + ? splitTextToTtsBlocksEPUB(canonicalText, splitOptions) + : splitTextToTtsBlocks(canonicalText, splitOptions); + + let rawCursor = 0; + let normalizedCursor = 0; + const segments: CanonicalTtsSegment[] = []; + + for (const block of blocks) { + const text = block.trim(); + if (!text) continue; + + const exactStart = canonicalText.indexOf(text, rawCursor); + let startOffset: number; + let endOffset: number; + + if (exactStart >= 0) { + startOffset = exactStart; + endOffset = exactStart + text.length; + rawCursor = endOffset; + normalizedCursor = normalizeWithRawMap(canonicalText.slice(0, endOffset)).text.length; + } else { + const flexible = findFlexibleOffset(canonicalText, text, normalizedCursor); + if (!flexible) continue; + startOffset = flexible.start; + endOffset = flexible.end; + rawCursor = endOffset; + normalizedCursor = flexible.normalizedEnd; + } + + const ownerSource = findSourceForOffset(preparedSources, startOffset, 'start'); + const endSource = findSourceForOffset(preparedSources, Math.max(startOffset, endOffset - 1), 'end'); + if (!ownerSource || !endSource) continue; + + if (ownerSource.sourceKey === endSource.sourceKey) { + // Block falls entirely within a single source — emit as-is. + const ordinal = segments.length; + const startAnchor = anchorForOffset(ownerSource, startOffset); + const endAnchor = anchorForOffset(endSource, endOffset); + segments.push({ + key: buildSegmentKey(keyPrefix, text), + ordinal, + text, + ownerSourceKey: ownerSource.sourceKey, + ownerLocator: ownerSource.locator, + startAnchor, + endAnchor, + spansSourceBoundary: false, + }); + } else { + // Block spans one or more source boundaries. Decide whether to keep it + // as a single boundary-spanning segment or to split it at each boundary. + // + // When all involved sources carry locators (e.g. current page → next + // page for PDF), we keep the unified boundary-spanning segment so that + // features like the PDF page-turn estimate and EPUB boundary handoff + // continue to work. + // + // When the block starts in a context-only source (locator === null), + // we need to handle two sub-cases: + // + // a) Clean boundary — the context portion ends at a sentence boundary + // (e.g. ends with ".!?"). In this case, each source's portion is a + // complete sentence; split the block so neither source absorbs the + // other's text. + // + // b) Overlapping sentence — the sentence genuinely spans from the + // context source into the real source. Keep the block unified and + // owned by the context-only source. The overlapping sentence was + // already played on the previous page via forward-looking + // continuation, so it should be filtered out of the current page's + // segments. + if (ownerSource.locator !== null) { + // Both sources carry locators → original unified boundary behavior. + const ordinal = segments.length; + const startAnchor = anchorForOffset(ownerSource, startOffset); + const endAnchor = anchorForOffset(endSource, endOffset); + segments.push({ + key: buildSegmentKey(keyPrefix, text), + ordinal, + text, + ownerSourceKey: ownerSource.sourceKey, + ownerLocator: ownerSource.locator, + startAnchor, + endAnchor, + spansSourceBoundary: true, + }); + } else { + // Owner is context-only. Find the first real source in this span. + const ownerIdx = preparedSources.indexOf(ownerSource); + const endIdx = preparedSources.indexOf(endSource); + let firstRealIdx = ownerIdx; + for (let i = ownerIdx; i <= endIdx; i += 1) { + if (preparedSources[i].locator !== null) { firstRealIdx = i; break; } + } + + // Check whether the context portion ends at a natural sentence + // boundary by looking at the trimmed text up to the source boundary. + const contextEnd = preparedSources[firstRealIdx].startOffset; + const contextPortion = canonicalText.slice(startOffset, contextEnd).trim(); + const isCleanBoundary = contextPortion.length > 0 + && /[.!?]["'""'')\]]*\s*$/.test(contextPortion); + + if (isCleanBoundary) { + // Clean boundary → split at each source boundary. + let subStart = startOffset; + for (let srcIdx = ownerIdx; srcIdx <= endIdx; srcIdx += 1) { + const source = preparedSources[srcIdx]; + const subEnd = srcIdx < endIdx ? source.endOffset : endOffset; + if (subEnd <= subStart) continue; + + const subText = canonicalText.slice(subStart, subEnd).trim(); + if (!subText) { + subStart = subEnd; + continue; + } + + const nextSource = srcIdx < endIdx ? preparedSources[srcIdx + 1] : null; + const subStartAnchor = anchorForOffset(source, subStart); + const subEndAnchor = anchorForOffset(source, subEnd); + const ordinal = segments.length; + segments.push({ + key: buildSegmentKey(keyPrefix, subText), + ordinal, + text: subText, + ownerSourceKey: source.sourceKey, + ownerLocator: source.locator, + startAnchor: subStartAnchor, + endAnchor: subEndAnchor, + spansSourceBoundary: false, + }); + subStart = nextSource ? nextSource.startOffset : subEnd; + } + } else { + // Overlapping sentence → keep unified, owned by the context-only + // source. This segment will be filtered out of the current page's + // segments since the context source has no locator and a different + // sourceKey. The sentence was already played on the previous page. + const ordinal = segments.length; + const startAnchor = anchorForOffset(ownerSource, startOffset); + const endAnchor = anchorForOffset(endSource, endOffset); + segments.push({ + key: buildSegmentKey(keyPrefix, text), + ordinal, + text, + ownerSourceKey: ownerSource.sourceKey, + ownerLocator: ownerSource.locator, + startAnchor, + endAnchor, + spansSourceBoundary: true, + }); + } + } + } + } + + return { + version: TTS_SEGMENT_PLAN_VERSION, + readerType, + text: canonicalText, + segments, + }; +} diff --git a/src/types/client.ts b/src/types/client.ts index e6fef9b..00a5463 100644 --- a/src/types/client.ts +++ b/src/types/client.ts @@ -102,6 +102,7 @@ export interface TTSSegmentLocator { export interface TTSSegmentInput { segmentIndex: number; + segmentKey?: string; text: string; locator?: TTSSegmentLocator; } @@ -115,6 +116,7 @@ export interface TTSSegmentsEnsureRequest { export interface TTSSegmentManifestItem { segmentId: string; segmentIndex: number; + segmentKey?: string | null; audioPresignUrl: string | null; audioFallbackUrl: string | null; durationMs: number; diff --git a/src/types/tts.ts b/src/types/tts.ts index 5909ac8..482168f 100644 --- a/src/types/tts.ts +++ b/src/types/tts.ts @@ -1,3 +1,5 @@ +import type { CanonicalTtsSegment } from '@/lib/shared/tts-segment-plan'; + export type TTSLocation = string | number; // Core audio representations used across TTS and audiobook flows @@ -24,17 +26,12 @@ export interface TTSPlaybackState { isPlaying: boolean; isProcessing: boolean; currentSentence: string; + currentSegment?: CanonicalTtsSegment | null; currDocPage: TTSLocation; currDocPageNumber: number; currDocPages?: number; } -// Result of merging a continuation slice into the current text -export interface TTSSmartMergeResult { - text: string; - carried: string; -} - // Estimate for when a visual page/section turn should occur during audio playback export interface TTSPageTurnEstimate { location: TTSLocation; diff --git a/tests/unit/epub-word-highlight.spec.ts b/tests/unit/epub-word-highlight.spec.ts new file mode 100644 index 0000000..719a58c --- /dev/null +++ b/tests/unit/epub-word-highlight.spec.ts @@ -0,0 +1,60 @@ +import { expect, test } from '@playwright/test'; + +import { + buildMonotonicWordToTokenMap, + tokenizeCanonicalSegment, +} from '../../src/lib/shared/epub-word-highlight'; +import type { CanonicalTtsSegment } from '../../src/lib/shared/tts-segment-plan'; +import type { TTSSentenceAlignment } from '../../src/types/tts'; + +const segment = (text: string, offset = 0): CanonicalTtsSegment => ({ + key: `segment:${offset}:${text}`, + ordinal: 0, + text, + ownerSourceKey: 'str:epubcfi(/6/2)', + ownerLocator: { location: 'epubcfi(/6/2)', readerType: 'epub' }, + startAnchor: { sourceKey: 'str:epubcfi(/6/2)', offset }, + endAnchor: { sourceKey: 'str:epubcfi(/6/2)', offset: offset + text.length }, + spansSourceBoundary: false, +}); + +const alignmentWords = (words: string[]): TTSSentenceAlignment['words'] => + words.map((word, index) => ({ + text: word, + startSec: index, + endSec: index + 0.5, + charStart: 0, + charEnd: word.length, + })); + +test.describe('EPUB word highlight mapping', () => { + test('tokenizes canonical segment words with source offsets', () => { + const tokens = tokenizeCanonicalSegment(segment('"Hello," she said.', 12)); + + expect(tokens).toEqual([ + { norm: 'hello', sourceStart: 13, sourceEnd: 18 }, + { norm: 'she', sourceStart: 21, sourceEnd: 24 }, + { norm: 'said', sourceStart: 25, sourceEnd: 29 }, + ]); + }); + + test('maps repeated words monotonically instead of jumping to later duplicates', () => { + const tokens = tokenizeCanonicalSegment(segment('the light and the shadow and the light returned')); + const map = buildMonotonicWordToTokenMap( + alignmentWords(['the', 'light', 'and', 'the', 'shadow', 'and', 'the', 'light', 'returned']), + tokens, + ); + + expect(map).toEqual([0, 1, 2, 3, 4, 5, 6, 7, 8]); + }); + + test('leaves unmatched alignment words unhighlighted instead of borrowing a neighbor', () => { + const tokens = tokenizeCanonicalSegment(segment('alpha beta gamma')); + const map = buildMonotonicWordToTokenMap( + alignmentWords(['alpha', 'missing', 'gamma']), + tokens, + ); + + expect(map).toEqual([0, -1, 2]); + }); +}); diff --git a/tests/unit/tts-epub-handoff.spec.ts b/tests/unit/tts-epub-handoff.spec.ts new file mode 100644 index 0000000..c80ebd2 --- /dev/null +++ b/tests/unit/tts-epub-handoff.spec.ts @@ -0,0 +1,115 @@ +import { expect, test } from '@playwright/test'; + +import { + completedEpubBoundarySegment, + resolveEpubBoundaryHandoffStartIndex, + resolveEpubReplaySuppressionAction, + shouldSuppressCompletedEpubBoundaryReplay, +} from '../../src/lib/shared/tts-epub-handoff'; +import type { CanonicalTtsSegment } from '../../src/lib/shared/tts-segment-plan'; + +const segment = ( + text: string, + overrides: Partial = {}, +): CanonicalTtsSegment => ({ + key: `segment:${text}`, + ordinal: 0, + text, + ownerSourceKey: 'str:page-a', + ownerLocator: { location: 'page-a', readerType: 'epub' }, + startAnchor: { sourceKey: 'str:page-a', offset: 0 }, + endAnchor: { sourceKey: 'str:page-a', offset: text.length }, + spansSourceBoundary: false, + ...overrides, +}); + +test.describe('EPUB boundary handoff', () => { + test('records only completed segments that span a source boundary', () => { + expect(completedEpubBoundarySegment(segment('same page'))).toBeNull(); + + const completed = completedEpubBoundarySegment(segment('cross page', { + spansSourceBoundary: true, + endAnchor: { sourceKey: 'str:page-b', offset: 5 }, + }), 100); + + expect(completed).toMatchObject({ + key: 'segment:cross page', + fingerprint: 'cross page', + completedAt: 100, + }); + }); + + test('skips a leading replay of the completed boundary segment', () => { + const completed = completedEpubBoundarySegment(segment('The same audio crosses the page.', { + key: 'old-key', + spansSourceBoundary: true, + endAnchor: { sourceKey: 'str:page-b', offset: 10 }, + }), 1000); + + const startIndex = resolveEpubBoundaryHandoffStartIndex([ + segment('The same audio crosses the page.', { key: 'new-cfi-key' }), + segment('Fresh audio on the next page.'), + ], completed, 1100); + + expect(startIndex).toBe(1); + }); + + test('does not skip stale or non-leading matches', () => { + const completed = completedEpubBoundarySegment(segment('Repeated later.', { + spansSourceBoundary: true, + endAnchor: { sourceKey: 'str:page-b', offset: 10 }, + }), 1000); + + expect(resolveEpubBoundaryHandoffStartIndex([ + segment('Fresh start.'), + segment('Repeated later.'), + ], completed, 1100)).toBe(0); + + expect(resolveEpubBoundaryHandoffStartIndex([ + segment('Repeated later.'), + ], completed, 200_000)).toBe(0); + }); + + test('suppresses a completed boundary segment at playback time even if setText handoff missed it', () => { + const completed = completedEpubBoundarySegment(segment('Boundary audio that already played.', { + key: 'completed-key', + spansSourceBoundary: true, + endAnchor: { sourceKey: 'str:page-b', offset: 10 }, + }), 1000); + + expect(shouldSuppressCompletedEpubBoundaryReplay( + segment('Boundary audio that already played.', { key: 'different-render-key' }), + completed, + 1500, + )).toBe(true); + + expect(shouldSuppressCompletedEpubBoundaryReplay( + segment('Actually fresh audio.'), + completed, + 1500, + )).toBe(false); + }); + + test('suppression skips only within the current rendered segment list', () => { + const completed = completedEpubBoundarySegment(segment('Boundary audio that already played.', { + spansSourceBoundary: true, + endAnchor: { sourceKey: 'str:page-b', offset: 10 }, + }), 1000); + + expect(resolveEpubReplaySuppressionAction([ + segment('Boundary audio that already played.', { key: 'rerendered-key' }), + segment('Fresh local audio.'), + ], 0, completed, 1100)).toEqual({ kind: 'skip-to-index', index: 1 }); + }); + + test('suppression pauses instead of requesting another page turn when no fresh local segment exists', () => { + const completed = completedEpubBoundarySegment(segment('Boundary audio that already played.', { + spansSourceBoundary: true, + endAnchor: { sourceKey: 'str:page-b', offset: 10 }, + }), 1000); + + expect(resolveEpubReplaySuppressionAction([ + segment('Boundary audio that already played.', { key: 'rerendered-key' }), + ], 0, completed, 1100)).toEqual({ kind: 'pause' }); + }); +}); diff --git a/tests/unit/tts-segment-plan-leading.spec.ts b/tests/unit/tts-segment-plan-leading.spec.ts new file mode 100644 index 0000000..689d36a --- /dev/null +++ b/tests/unit/tts-segment-plan-leading.spec.ts @@ -0,0 +1,185 @@ +import { expect, test } from '@playwright/test'; + +import { planCanonicalTtsSegments } from '../../src/lib/shared/tts-segment-plan'; + +test.describe('planCanonicalTtsSegments – leading context handling', () => { + test('clean boundary: splits block so current source keeps its first sentence', () => { + const currentSourceKey = 'str:epubcfi(/6/10!/4/2)'; + const plan = planCanonicalTtsSegments([ + { + sourceKey: `previous:${currentSourceKey}`, + text: 'This is the last sentence from the previous page. It ends cleanly here.', + locator: null, + }, + { + sourceKey: currentSourceKey, + text: 'The first sentence on this page starts fresh. And here is the second sentence.', + locator: { location: 'epubcfi(/6/10!/4/2)', readerType: 'epub' as const }, + }, + ], { + readerType: 'epub', + maxBlockLength: 450, + keyPrefix: 'doc:epub:v1', + }); + + const currentSegments = plan.segments.filter(seg => seg.ownerSourceKey === currentSourceKey); + expect(currentSegments.length).toBe(1); + expect(currentSegments[0].text).toContain('The first sentence on this page'); + expect(currentSegments[0].startAnchor.sourceKey).toBe(currentSourceKey); + expect(currentSegments[0].startAnchor.offset).toBe(0); + expect(currentSegments[0].spansSourceBoundary).toBe(false); + }); + + test('overlapping sentence: stays owned by context source, filtered out of current page', () => { + const currentSourceKey = 'str:epubcfi(/6/10!/4/2)'; + const previousSourceKey = `previous:${currentSourceKey}`; + const plan = planCanonicalTtsSegments([ + { + sourceKey: previousSourceKey, + text: 'The sentence starts on the previous page and', + locator: null, + }, + { + sourceKey: currentSourceKey, + text: 'finishes on the current page with enough context. Next sentence here.', + locator: { location: 'epubcfi(/6/10!/4/2)', readerType: 'epub' as const }, + }, + ], { + readerType: 'epub', + maxBlockLength: 450, + keyPrefix: 'doc:epub:v1', + }); + + // The overlapping sentence should stay owned by the context source. + const contextSegments = plan.segments.filter(seg => seg.ownerSourceKey === previousSourceKey); + expect(contextSegments.length).toBeGreaterThanOrEqual(1); + expect(contextSegments[0].text).toContain('The sentence starts on the previous page and finishes on the current page'); + expect(contextSegments[0].spansSourceBoundary).toBe(true); + + // Current page segments should NOT include the overlapping sentence. + const currentSegments = plan.segments.filter(seg => seg.ownerSourceKey === currentSourceKey); + for (const seg of currentSegments) { + expect(seg.text).not.toContain('previous page'); + } + }); + + test('clean boundary with longer text: preserves first sentence', () => { + const currentSourceKey = 'str:epubcfi(/6/10!/4/2)'; + const longPreviousText = 'This is a much longer paragraph from the previous page. It contains several sentences that fill up more space. ' + + 'The story continues with more details about what happened. Characters move through the scene with purpose. ' + + 'Each step brings them closer to their destination. The sun sets slowly behind the mountains. ' + + 'Wind rustles through the leaves of the ancient trees. Birds call out their evening songs.'; + + const currentText = 'The next morning dawned bright and clear. She stepped outside and breathed in the fresh air. ' + + 'The garden was blooming with colorful flowers. Bees buzzed lazily from blossom to blossom. ' + + 'It was the kind of day that made everything seem possible. She smiled to herself and walked down the path.'; + + const plan = planCanonicalTtsSegments([ + { + sourceKey: `previous:${currentSourceKey}`, + text: longPreviousText, + locator: null, + }, + { + sourceKey: currentSourceKey, + text: currentText, + locator: { location: 'epubcfi(/6/10!/4/2)', readerType: 'epub' as const }, + }, + ], { + readerType: 'epub', + maxBlockLength: 450, + keyPrefix: 'doc:epub:v1', + }); + + const currentSegments = plan.segments.filter(seg => seg.ownerSourceKey === currentSourceKey); + const reconstructed = currentSegments.map(s => s.text).join(' '); + expect(reconstructed).toContain('The next morning dawned bright and clear'); + expect(reconstructed).toContain('She stepped outside'); + expect(reconstructed).toContain('She smiled to herself'); + }); + + test('forward-looking boundary between real sources is preserved', () => { + const plan = planCanonicalTtsSegments([ + { + sourceKey: 'page:1', + locator: { page: 1, readerType: 'pdf' as const }, + text: 'The boundary sentence begins on page one and', + }, + { + sourceKey: 'page:2', + locator: { page: 2, readerType: 'pdf' as const }, + text: 'finishes on page two with enough words to stand alone. A short follow up.', + }, + ], { + readerType: 'pdf', + maxBlockLength: 60, + keyPrefix: 'doc:v1', + }); + + // Forward boundary between two real sources stays unified. + expect(plan.segments[0]).toMatchObject({ + ownerSourceKey: 'page:1', + spansSourceBoundary: true, + }); + expect(plan.segments[0].text).toContain('begins on page one and finishes on page two'); + }); + + test('overlapping sentence with comma: stays owned by context source', () => { + const currentSourceKey = 'str:epubcfi(/6/14!/4/2)'; + const previousSourceKey = `previous:${currentSourceKey}`; + const plan = planCanonicalTtsSegments([ + { + sourceKey: previousSourceKey, + text: 'She walked down the lane, past the old church,', + locator: null, + }, + { + sourceKey: currentSourceKey, + text: 'and turned left at the river. The bridge was old.', + locator: { location: 'epubcfi(/6/14!/4/2)', readerType: 'epub' as const }, + }, + ], { + readerType: 'epub', + maxBlockLength: 450, + keyPrefix: 'doc:epub:v1', + }); + + // Comma is not sentence-ending punctuation → overlapping → owned by context. + const contextSegments = plan.segments.filter(seg => seg.ownerSourceKey === previousSourceKey); + expect(contextSegments.length).toBeGreaterThanOrEqual(1); + expect(contextSegments[0].text).toContain('She walked down the lane'); + expect(contextSegments[0].text).toContain('and turned left at the river'); + + // Current page should NOT include the overlapping sentence. + const currentSegments = plan.segments.filter(seg => seg.ownerSourceKey === currentSourceKey); + for (const seg of currentSegments) { + expect(seg.text).not.toContain('She walked down the lane'); + } + }); + + test('clean boundary with quoted speech ending in period', () => { + const currentSourceKey = 'str:epubcfi(/6/16!/4/2)'; + const plan = planCanonicalTtsSegments([ + { + sourceKey: `previous:${currentSourceKey}`, + text: '"I will be back tomorrow," she said.', + locator: null, + }, + { + sourceKey: currentSourceKey, + text: 'The door closed behind her. Silence filled the room.', + locator: { location: 'epubcfi(/6/16!/4/2)', readerType: 'epub' as const }, + }, + ], { + readerType: 'epub', + maxBlockLength: 450, + keyPrefix: 'doc:epub:v1', + }); + + const currentSegments = plan.segments.filter(seg => seg.ownerSourceKey === currentSourceKey); + expect(currentSegments.length).toBe(1); + // Clean boundary after quoted speech → split correctly. + expect(currentSegments[0].text).toContain('The door closed behind her'); + expect(currentSegments[0].text).not.toContain('I will be back'); + }); +}); diff --git a/tests/unit/tts-segment-plan.spec.ts b/tests/unit/tts-segment-plan.spec.ts new file mode 100644 index 0000000..d125a30 --- /dev/null +++ b/tests/unit/tts-segment-plan.spec.ts @@ -0,0 +1,167 @@ +import { expect, test } from '@playwright/test'; + +import { planCanonicalTtsSegments } from '../../src/lib/shared/tts-segment-plan'; + +test.describe('planCanonicalTtsSegments', () => { + test('emits a cross-boundary segment once and assigns it to the source where it starts', () => { + const plan = planCanonicalTtsSegments([ + { + sourceKey: 'page:1', + locator: { page: 1, readerType: 'pdf' }, + text: 'The boundary sentence begins on page one and', + }, + { + sourceKey: 'page:2', + locator: { page: 2, readerType: 'pdf' }, + text: 'finishes on page two with enough words to stand alone. A short follow up.', + }, + ], { + readerType: 'pdf', + maxBlockLength: 60, + keyPrefix: 'doc:v1', + }); + + expect(plan.segments.length).toBeGreaterThanOrEqual(2); + expect(plan.segments[0]).toMatchObject({ + ownerSourceKey: 'page:1', + ownerLocator: { page: 1, readerType: 'pdf' }, + spansSourceBoundary: true, + }); + expect(plan.segments[0].text).toContain('begins on page one and finishes on page two'); + expect(plan.segments[1]).toMatchObject({ + ownerSourceKey: 'page:2', + spansSourceBoundary: false, + }); + expect(plan.segments[1].text).toContain('A short follow up.'); + }); + + test('returns the same segment keys for the same canonical source anchors', () => { + const options = { + readerType: 'epub' as const, + maxBlockLength: 70, + keyPrefix: 'book:v1', + }; + + const firstPlan = planCanonicalTtsSegments([ + { + sourceKey: 'chapter:1', + text: 'First sentence with enough words to be its own block. Second sentence with enough words to be its own block.', + }, + ], options); + + const secondPlan = planCanonicalTtsSegments([ + { + sourceKey: 'chapter:1', + text: 'First sentence with enough words to be its own block. Second sentence with enough words to be its own block.', + }, + ], options); + + expect(secondPlan.segments.map((segment) => segment.text)).toEqual( + firstPlan.segments.map((segment) => segment.text), + ); + expect(secondPlan.segments.map((segment) => segment.key)).toEqual( + firstPlan.segments.map((segment) => segment.key), + ); + }); + + test('repeated identical text produces the same content-addressed key', () => { + const repeated = 'Repeat this exact sentence with enough filler words here.'; + const plan = planCanonicalTtsSegments([ + { sourceKey: 'chapter:1', text: `${repeated} ${repeated}` }, + ], { + readerType: 'epub', + maxBlockLength: 50, + keyPrefix: 'book:v1', + }); + + expect(plan.segments).toHaveLength(2); + expect(plan.segments[0].text).toBe(repeated); + expect(plan.segments[1].text).toBe(repeated); + // Content-addressed keys: same text → same key (viewport-independent). + // Segments remain distinct via ordinal and anchors for client-side use. + expect(plan.segments[0].key).toBe(plan.segments[1].key); + expect(plan.segments[0].ordinal).not.toBe(plan.segments[1].ordinal); + }); + + test('does not let locator changes alter canonical keys', () => { + const text = 'Locator changes should not alter this stable segment identity.'; + const a = planCanonicalTtsSegments([ + { sourceKey: 'page:1', locator: { page: 1, readerType: 'pdf' }, text }, + ], { + readerType: 'pdf', + keyPrefix: 'doc:v1', + }); + const b = planCanonicalTtsSegments([ + { sourceKey: 'page:1', locator: { page: 99, readerType: 'pdf' }, text }, + ], { + readerType: 'pdf', + keyPrefix: 'doc:v1', + }); + + expect(a.segments.map((segment) => segment.key)).toEqual( + b.segments.map((segment) => segment.key), + ); + expect(a.segments[0].ownerLocator).toEqual({ page: 1, readerType: 'pdf' }); + expect(b.segments[0].ownerLocator).toEqual({ page: 99, readerType: 'pdf' }); + }); + + test('generates identical segment keys for the same text from different viewports (sourceKeys)', () => { + const sentence = 'This sentence is rendered on different viewports.'; + + // Viewport A (e.g. narrow device) + const planA = planCanonicalTtsSegments([ + { sourceKey: 'epubcfi(/6/10!/4/2)', text: sentence }, + ], { + readerType: 'epub', + keyPrefix: 'book:v1', + }); + + // Viewport B (e.g. wide device) + const planB = planCanonicalTtsSegments([ + { sourceKey: 'epubcfi(/6/12!/4/4)', text: sentence }, + ], { + readerType: 'epub', + keyPrefix: 'book:v1', + }); + + // Content-addressed keys should match perfectly because the text is the same + expect(planA.segments[0].key).toBe(planB.segments[0].key); + + // But the client-side anchoring accurately reflects the different viewport source + expect(planA.segments[0].ownerSourceKey).toBe('epubcfi(/6/10!/4/2)'); + expect(planB.segments[0].ownerSourceKey).toBe('epubcfi(/6/12!/4/4)'); + expect(planA.segments[0].startAnchor.sourceKey).not.toBe(planB.segments[0].startAnchor.sourceKey); + }); + + test('anchors segment offsets back to normalized source text', () => { + const plan = planCanonicalTtsSegments([ + { sourceKey: 'page:1', text: 'First sentence. ' }, + { sourceKey: 'page:2', text: ' Second sentence with enough words to be separate.' }, + ], { + readerType: 'epub', + maxBlockLength: 50, + keyPrefix: 'book:v1', + }); + + expect(plan.segments).toHaveLength(2); + expect(plan.segments[0].startAnchor).toEqual({ sourceKey: 'page:1', offset: 0 }); + expect(plan.segments[0].endAnchor.sourceKey).toBe('page:1'); + expect(plan.segments[1].startAnchor).toEqual({ sourceKey: 'page:2', offset: 0 }); + expect(plan.text).toBe('First sentence. Second sentence with enough words to be separate.'); + }); + + test('ignores empty source units', () => { + const plan = planCanonicalTtsSegments([ + { sourceKey: 'empty:1', text: ' ' }, + { sourceKey: 'page:1', text: 'Only useful text remains.' }, + { sourceKey: 'empty:2', text: '\n\n' }, + ], { + readerType: 'pdf', + keyPrefix: 'doc:v1', + }); + + expect(plan.text).toBe('Only useful text remains.'); + expect(plan.segments).toHaveLength(1); + expect(plan.segments[0].ownerSourceKey).toBe('page:1'); + }); +}); diff --git a/tests/unit/tts-segments-manifest.spec.ts b/tests/unit/tts-segments-manifest.spec.ts index 402fbcf..98b306a 100644 --- a/tests/unit/tts-segments-manifest.spec.ts +++ b/tests/unit/tts-segments-manifest.spec.ts @@ -98,6 +98,17 @@ test.describe('tts segments manifest helpers', () => { expect(sorted.map((row) => row.groupKey)).toEqual(['b', 'd', 'a', 'c']); }); + test('sorts EPUB CFI locations naturally instead of lexicographically', () => { + const rows = [ + { groupKey: 'ten', segmentIndex: 0, locator: { location: 'epubcfi(/6/10!/4/2)', readerType: 'epub' as const } }, + { groupKey: 'two-b', segmentIndex: 1, locator: { location: 'epubcfi(/6/2!/4/2)', readerType: 'epub' as const } }, + { groupKey: 'two-a', segmentIndex: 0, locator: { location: 'epubcfi(/6/2!/4/2)', readerType: 'epub' as const } }, + ]; + + const sorted = rows.sort(compareManifestSegments); + expect(sorted.map((row) => row.groupKey)).toEqual(['two-a', 'two-b', 'ten']); + }); + test('encodes and decodes cursors', () => { const raw = '3|p:2|l:epubcfi(/6/2)|r:epub'; const encoded = encodeManifestCursor(raw); diff --git a/tests/unit/tts-segments.spec.ts b/tests/unit/tts-segments.spec.ts index 1c5c3bb..ef3e21e 100644 --- a/tests/unit/tts-segments.spec.ts +++ b/tests/unit/tts-segments.spec.ts @@ -57,6 +57,38 @@ test.describe('tts segment helpers', () => { expect(id1).not.toBe(id3); }); + test('canonical segment key makes id independent of locator and index', () => { + const id1 = buildTtsSegmentId({ + documentId: 'doc', + documentVersion: 1, + settingsHash: 'abc', + segmentIndex: 2, + segmentKey: 'doc:v1:segment-a', + normalizedText: 'hello world', + locatorFingerprint: 'loc-a', + }); + const id2 = buildTtsSegmentId({ + documentId: 'doc', + documentVersion: 1, + settingsHash: 'abc', + segmentIndex: 99, + segmentKey: 'doc:v1:segment-a', + normalizedText: 'hello world', + locatorFingerprint: 'loc-b', + }); + const id3 = buildTtsSegmentId({ + documentId: 'doc', + documentVersion: 1, + settingsHash: 'abc', + segmentIndex: 2, + segmentKey: 'doc:v1:segment-b', + normalizedText: 'hello world', + locatorFingerprint: 'loc-a', + }); + expect(id1).toBe(id2); + expect(id1).not.toBe(id3); + }); + test('does not leak plaintext via text hash', () => { const hash = buildTtsSegmentTextHash('plain sentence', 'secret'); expect(hash).not.toContain('plain');