diff --git a/src/app/(app)/epub/[id]/useEpubDocument.ts b/src/app/(app)/epub/[id]/useEpubDocument.ts index 14c38fa..5cc5427 100644 --- a/src/app/(app)/epub/[id]/useEpubDocument.ts +++ b/src/app/(app)/epub/[id]/useEpubDocument.ts @@ -16,6 +16,11 @@ import { getDocumentMetadata } from '@/lib/client/api/documents'; import { ensureCachedDocument } from '@/lib/client/cache/documents'; import { EpubRenderedLocationCloneManager } from '@/lib/client/epub/rendered-location-walker'; import { canonicalizeEpubSegmentAgainstSpineText } from '@/lib/client/epub/canonicalize-epub-segment'; +import { + buildEpubCanonicalWindow, + buildEpubCanonicalWindowFromChunk, + materializeWindowSegments, +} from '@/lib/client/epub/epub-canonical-window'; import { buildEpubLocator, getSpineItemPlainText } from '@/lib/client/epub/spine-coordinates'; import { useTTS, type EpubLocatorResolver } from '@/contexts/TTSContext'; import { createRangeCfi } from '@/lib/client/epub'; @@ -43,9 +48,13 @@ import type { } from '@/types/tts'; import type { AudiobookGenerationSettings, TTSSegmentLocator } from '@/types/client'; import { isStableEpubLocator } from '@/types/client'; -import type { CanonicalTtsSegment } from '@/lib/shared/tts-segment-plan'; +import { buildSegmentKeyPrefix, type CanonicalTtsSegment } from '@/lib/shared/tts-segment-plan'; import { normalizeOptionalLanguageTag } from '@/lib/shared/language'; +// How many canonical segments to pre-stage for the next page so a +// background-tab page turn can keep speaking without waiting on the rendition. +const EPUB_PREFETCH_SEGMENT_COUNT = 24; + export interface EpubDocumentState { currDocData: ArrayBuffer | undefined; currDocName: string | undefined; @@ -242,6 +251,17 @@ export function useEpubDocument(documentId?: string): EpubDocumentState { const { start, end } = location; if (!start?.cfi || !end?.cfi || !book || !book.isOpen || !rendition) return ''; + // Guard against stale async completion: this function awaits range + + // canonical-plan resolution, during which rapid page turns can move the + // rendition on. If the live location no longer matches the page we + // captured, bail before writing state so we don't overwrite the active + // page's segments/highlights with a superseded page's. + const capturedStartCfi = start.cfi; + const isStale = (): boolean => { + const live = rendition.location?.start?.cfi; + return Boolean(live) && live !== capturedStartCfi; + }; + const rangeCfi = createRangeCfi(start.cfi, end.cfi); const range = await book.getRange(rangeCfi); @@ -249,22 +269,77 @@ export function useEpubDocument(documentId?: string): EpubDocumentState { console.warn('Failed to get range from CFI:', rangeCfi); return ''; } + if (isStale()) return ''; const textContent = range.toString().trim(); setRenderedTextMaps(buildRenderedTextMaps( rendition, rangeCfi, normalizeTtsLocationKey(start.cfi), )); - const leadingPreview = collectLeadingContextFromRange(range); - const continuationPreview = collectContinuationFromRange(range); - setTTSText(textContent, { - shouldPause, - location: start.cfi, - previousText: leadingPreview, - nextLocation: end.cfi, - nextText: continuationPreview + // Canonical path: derive this page's TTS segments as a window into the + // chapter's single, viewport-independent canonical plan. This is what + // keeps a block that straddles a page break identical (same key/ordinal) + // on both pages, so playback never repeats or wrongly pauses at the seam. + const keyPrefix = buildSegmentKeyPrefix(documentId, 'epub'); + const canonicalWindow = await buildEpubCanonicalWindow(book, { + startCfi: start.cfi, + viewportText: textContent, + keyPrefix, + maxBlockLength: ttsSegmentMaxBlockLength, + language: resolvedLanguage, + // Match the rendered text map's sourceKey so the page's canonical + // segments resolve to highlight ranges. + viewportAnchorSourceKey: normalizeTtsLocationKey(start.cfi), }); + + // The plan resolution above can load + segment a whole chapter; re-check + // that we're still on the captured page before committing TTS state. + if (isStale()) return ''; + + if (canonicalWindow) { + // Stage the next page's canonical segments (the slice immediately after + // this window) so a hidden-tab page turn keeps speaking canonical + // segments; ordinal continuity in TTSContext de-dupes the shared seam. + const nextStart = canonicalWindow.windowEndOrdinal + 1; + const nextSegments = nextStart < canonicalWindow.plan.length + ? materializeWindowSegments( + canonicalWindow.plan, + nextStart, + Math.min(canonicalWindow.plan.length - 1, nextStart + EPUB_PREFETCH_SEGMENT_COUNT - 1), + { + spineHref: canonicalWindow.spineHref, + spineIndex: canonicalWindow.spineIndex, + cfi: end.cfi, + }, + ) + : []; + + setTTSText(textContent, { + shouldPause, + location: start.cfi, + nextLocation: end.cfi, + canonicalSegments: canonicalWindow.segments, + canonicalSpine: { + spineHref: canonicalWindow.spineHref, + spineIndex: canonicalWindow.spineIndex, + }, + canonicalNextSegments: nextSegments.length > 0 ? nextSegments : undefined, + }); + } else { + // Fallback (spine→spine boundary, footnote/nav/image pages, or text not + // indexable in the spine): legacy preview-based plan + fuzzy handoff. + const leadingPreview = collectLeadingContextFromRange(range); + const continuationPreview = collectContinuationFromRange(range); + setTTSText(textContent, { + shouldPause, + location: start.cfi, + previousText: leadingPreview, + nextLocation: end.cfi, + nextText: continuationPreview, + }); + } + setCurrDocText(textContent); setIsPlaybackReady(true); @@ -273,7 +348,7 @@ export function useEpubDocument(documentId?: string): EpubDocumentState { console.error('Error extracting EPUB text:', error); return ''; } - }, [setRenderedTextMaps, setTTSText]); + }, [setRenderedTextMaps, setTTSText, documentId, ttsSegmentMaxBlockLength, resolvedLanguage]); /** * Resolves a draft EPUB locator (typically `{ readerType: 'epub', location: @@ -315,6 +390,7 @@ export function useEpubDocument(documentId?: string): EpubDocumentState { cfi: stable.cfi, keyPrefix: options?.keyPrefix, maxBlockLength: options?.maxBlockLength ?? ttsSegmentMaxBlockLength, + language: resolvedLanguage, }); if (!canonical) return null; @@ -324,7 +400,7 @@ export function useEpubDocument(documentId?: string): EpubDocumentState { segmentIndex: canonical.segmentIndex, text: canonical.text, }; - }, [ttsSegmentMaxBlockLength]); + }, [ttsSegmentMaxBlockLength, resolvedLanguage]); const walkUpcomingRenderedLocations = useCallback(async (startCfi, depth, signal) => { if (!startCfi || depth <= 0 || signal.aborted) return []; @@ -366,7 +442,7 @@ export function useEpubDocument(documentId?: string): EpubDocumentState { } : null; - return renderedLocationCloneManagerRef.current.walk({ + const items = await renderedLocationCloneManagerRef.current.walk({ data: currDocData, startCfi, depth, @@ -376,7 +452,32 @@ export function useEpubDocument(documentId?: string): EpubDocumentState { spread: visibleSettings?.spread, theme, }); - }, [currDocData, epubTheme]); + + // Enrich each walked chunk with canonical segments from the live book's + // cached chapter plan, so preload warms audio under the same keys/locators + // playback will request. Best-effort: a chunk that can't be canonicalized + // is left bare and falls back to preview-based planning downstream. + const liveBook = bookRef.current; + if (signal.aborted || !liveBook?.isOpen || items.length === 0) return items; + const keyPrefix = buildSegmentKeyPrefix(documentId, 'epub'); + return Promise.all(items.map(async (item) => { + try { + const chunkWindow = await buildEpubCanonicalWindowFromChunk(liveBook, { + spineHref: item.spineHref, + spineIndex: item.spineIndex, + chunkOffset: item.chunkOffset, + text: item.text, + cfi: item.cfi, + keyPrefix, + maxBlockLength: ttsSegmentMaxBlockLength, + language: resolvedLanguage, + }); + return chunkWindow ? { ...item, segments: chunkWindow.segments } : item; + } catch { + return item; + } + })); + }, [currDocData, epubTheme, documentId, ttsSegmentMaxBlockLength, resolvedLanguage]); const { createFullAudioBook, regenerateChapter } = useEPUBAudiobook({ bookRef, diff --git a/src/components/reader/SegmentsSidebar.tsx b/src/components/reader/SegmentsSidebar.tsx index 33d2434..5f955d7 100644 --- a/src/components/reader/SegmentsSidebar.tsx +++ b/src/components/reader/SegmentsSidebar.tsx @@ -220,28 +220,36 @@ export function SegmentsSidebar({ isOpen, setIsOpen, documentId, epubBookRef }: } let cancelled = false; void (async () => { - const spine = resolveSpineFromCfi(book, currDocPage); - if (!spine) { + try { + const spine = resolveSpineFromCfi(book, currDocPage); + if (!spine) { + if (!cancelled) setSynthRowCanonical([]); + return; + } + const spineText = await getSpineItemPlainText(book, spine.href); + if (cancelled) return; + const offsets = resolveMonotonicSentenceOffsets(spineText, sentences); + const next = canonicalizeEpubSegmentsAgainstSpineText({ + segmentTexts: sentences, + hintCharOffsets: offsets, + spineText, + spineHref: spine.href, + spineIndex: spine.index, + cfi: currDocPage, + keyPrefix: buildSegmentKeyPrefix(documentId, activeReaderType), + maxBlockLength: ttsSegmentMaxBlockLength, + language: resolvedLanguage, + }); + if (!cancelled) setSynthRowCanonical(next); + } catch (error) { + // Don't leave a previous page's canonical mapping in place if this + // resolution fails — clear it so we fall back to non-canonical rows. + console.warn('Failed to canonicalize EPUB sidebar segments:', error); if (!cancelled) setSynthRowCanonical([]); - return; } - const spineText = await getSpineItemPlainText(book, spine.href); - if (cancelled) return; - const offsets = resolveMonotonicSentenceOffsets(spineText, sentences); - const next = canonicalizeEpubSegmentsAgainstSpineText({ - segmentTexts: sentences, - hintCharOffsets: offsets, - spineText, - spineHref: spine.href, - spineIndex: spine.index, - cfi: currDocPage, - keyPrefix: buildSegmentKeyPrefix(documentId, activeReaderType), - maxBlockLength: ttsSegmentMaxBlockLength, - }); - if (!cancelled) setSynthRowCanonical(next); })(); return () => { cancelled = true; }; - }, [epubBookRef, currDocPage, sentences, documentId, activeReaderType, ttsSegmentMaxBlockLength]); + }, [epubBookRef, currDocPage, sentences, documentId, activeReaderType, ttsSegmentMaxBlockLength, resolvedLanguage]); const listRef = useRef(null); const didAutoScrollOnOpenRef = useRef(false); diff --git a/src/contexts/TTSContext.tsx b/src/contexts/TTSContext.tsx index fb15eac..de5aa24 100644 --- a/src/contexts/TTSContext.tsx +++ b/src/contexts/TTSContext.tsx @@ -171,6 +171,16 @@ interface SetTextOptions { nextSourceUnits?: CanonicalTtsSourceUnit[]; previousText?: string; upcomingLocations?: Array<{ location: TTSLocation; text: string; sourceUnits?: CanonicalTtsSourceUnit[] }>; + /** + * EPUB canonical path: pre-windowed segments for the current page, sliced + * from the chapter's single canonical plan (stable key + global `ordinal`). + * When present, setText uses them verbatim and skips preview-based planning. + */ + canonicalSegments?: CanonicalTtsSegment[]; + /** Spine identity of `canonicalSegments`, for the ordinal-continuity gate. */ + canonicalSpine?: { spineHref: string; spineIndex: number }; + /** Canonical segments for the next page, staged for the hidden-tab fast path. */ + canonicalNextSegments?: CanonicalTtsSegment[]; } type TTSSegmentPlaybackSource = { @@ -353,6 +363,33 @@ const resolveJumpIndex = (input: JumpResolutionInput): JumpResolution => { return { kind: 'fresh' }; }; +/** + * Resolve the local start index for an EPUB canonical-window page using ordinal + * continuity. The window's segments carry chapter-global `ordinal`s; we begin at + * the first one past the highest ordinal already spoken in this same spine item. + * + * Only trims on a *forward, contiguous* turn — when the window overlaps or sits + * just after what we've played. A backward turn or a non-contiguous jump returns + * 0 (play the whole window from the top); a different spine item returns 0. + */ +const resolveCanonicalStartIndex = ( + segments: CanonicalTtsSegment[], + spine: { spineHref: string; spineIndex: number } | null, + lastPlayed: { spineHref: string; spineIndex: number; ordinal: number } | null, +): number => { + if (segments.length === 0) return 0; + if (!spine || !lastPlayed) return 0; + if (lastPlayed.spineHref !== spine.spineHref || lastPlayed.spineIndex !== spine.spineIndex) return 0; + + const windowStart = segments[0].ordinal; + const windowEnd = segments[segments.length - 1].ordinal; + if (windowStart > lastPlayed.ordinal + 1) return 0; // jumped ahead → play whole window + if (windowEnd < lastPlayed.ordinal) return 0; // moved backward → play whole window + + const idx = segments.findIndex((segment) => segment.ordinal > lastPlayed.ordinal); + return idx < 0 ? segments.length : idx; +}; + const sourceKeyForLocation = (location: TTSLocation | undefined, fallback: TTSLocation): string => normalizeLocationKey(location ?? fallback); @@ -618,7 +655,7 @@ export function TTSProvider({ children }: { children: ReactNode }): ReactElement // values, so the strict locationKey match in pendingJumpTargetRef misses on // cross-spine jumps. We instead bump an epoch on each playFromSegment call // and let the next setText with a matching epoch consume the jump. - const pendingEpubJumpRef = useRef<{ index: number; epoch: number } | null>(null); + const pendingEpubJumpRef = useRef<{ index: number; epoch: number; charOffset?: number } | null>(null); const epubJumpEpochRef = useRef(0); const epubPreloadGenerationRef = useRef(0); const epubWalkInFlightRef = useRef>(new Set()); @@ -644,6 +681,12 @@ export function TTSProvider({ children }: { children: ReactNode }): ReactElement const currentSourceUnitRef = useRef(null); const currentSourceContextUnitsRef = useRef([]); const completedEpubBoundarySegmentRef = useRef(null); + // Highest canonical segment ordinal already spoken in the current spine item. + // Drives exact, viewport-independent page-turn handoff: the next page begins + // at the first window segment whose ordinal exceeds this. Replaces fuzzy + // fingerprint matching for within-chapter turns (the spine→spine handoff in + // tts-epub-handoff.ts remains the fallback path's safety net). + const lastPlayedCanonicalRef = useRef<{ spineHref: string; spineIndex: number; ordinal: number } | null>(null); const pendingNextLocationRef = useRef(undefined); const audioUnlockAttemptRef = useRef(0); @@ -1060,7 +1103,28 @@ export function TTSProvider({ children }: { children: ReactNode }): ReactElement } } } - locationChangeHandlerRef.current(nextIndex >= sentences.length ? 'next' : 'prev'); + const direction = nextIndex >= sentences.length ? 'next' : 'prev'; + // EPUB navigation is asynchronous (rendition.next/prev → relocated → + // skipToLocation → setText), unlike PDF which resets synchronously via + // skipToLocation right here. Without clearing the just-finished page now, + // an unrelated re-render during the async gap — e.g. setActiveHowl(null) + // in onend changing playAudio's identity — re-fires the playback effect + // against the *stale* last index and replays the final segment before the + // next page loads. Reset synchronously to get PDF's deterministic handoff. + if (isPlayingRef.current) { + resumeAfterLocationChangeRef.current = true; + } + if (backwards) { + // Backward navigation breaks forward ordinal continuity. + lastPlayedCanonicalRef.current = null; + } + invalidatePlaybackRun(); + setCurrentIndex(0); + setSentences([]); + setPlaybackSegments([]); + setCurrentSentenceAlignment(undefined); + setCurrentWordIndex(null); + locationChangeHandlerRef.current(direction); return; } @@ -1111,7 +1175,7 @@ export function TTSProvider({ children }: { children: ReactNode }): ReactElement setIsPlaying(false); } } - }, [currentIndex, sentences, currDocPageNumber, currDocPages, isEPUB, skipToLocation]); + }, [currentIndex, sentences, currDocPageNumber, currDocPages, isEPUB, skipToLocation, invalidatePlaybackRun]); /** * Handles blank text sections based on document type @@ -1178,87 +1242,121 @@ export function TTSProvider({ children }: { children: ReactNode }): ReactElement const effectiveCurrentUnits = currentUnits && currentUnits.length > 0 ? currentUnits : [currentSource]; const currentSourceKeySet = new Set(effectiveCurrentUnits.map((unit) => unit.sourceKey)); - const contextSourceUnits: CanonicalTtsSourceUnit[] = []; - if (normalizedOptions.previousText?.trim()) { - const previousLocation = normalizedOptions.previousLocation; - contextSourceUnits.push({ - sourceKey: previousLocation !== undefined - ? sourceKeyForLocation(previousLocation, currDocPage) - : `previous:${currentSourceKey}`, - text: normalizedOptions.previousText, - locator: previousLocation !== undefined - ? locatorForLocation(previousLocation, activeReaderType) - : null, - }); - } - contextSourceUnits.push(...effectiveCurrentUnits); - const sourceUnits: CanonicalTtsSourceUnit[] = [...contextSourceUnits]; + // EPUB canonical path: the caller already windowed this page out of the + // chapter's single canonical plan, so we use those segments verbatim and + // skip preview-based planning entirely. This is what makes a page-straddling + // block identical (same key + global ordinal) on both pages. + const useCanonicalEpub = isEPUB + && Array.isArray(normalizedOptions.canonicalSegments) + && normalizedOptions.canonicalSegments.length > 0; - plannedSegmentsByLocationRef.current.clear(); - pendingNextLocationRef.current = normalizedOptions.nextLocation; - const pendingPrefetches: Array<{ - location: TTSLocation; - sourceUnits: CanonicalTtsSourceUnit[]; - }> = []; - if (normalizedOptions.nextLocation !== undefined && normalizedOptions.nextText?.trim()) { - const provided = normalizedOptions.nextSourceUnits?.filter((unit) => unit.text.trim().length > 0) ?? []; - pendingPrefetches.push(provided.length > 0 - ? { - location: normalizedOptions.nextLocation, - sourceUnits: provided, - } - : { - location: normalizedOptions.nextLocation, - sourceUnits: [{ - sourceKey: sourceKeyForLocation(normalizedOptions.nextLocation, currDocPage), - text: normalizedOptions.nextText, - locator: locatorForLocation(normalizedOptions.nextLocation, activeReaderType), - }], - }); - } - if (Array.isArray(normalizedOptions.upcomingLocations)) { - for (const item of normalizedOptions.upcomingLocations) { - if (item.location === undefined || !item.text?.trim()) continue; - const provided = item.sourceUnits?.filter((unit) => unit.text.trim().length > 0) ?? []; + let currentSegments: CanonicalTtsSegment[]; + let newSentences: string[]; + + if (useCanonicalEpub) { + currentSegments = normalizedOptions.canonicalSegments!; + newSentences = currentSegments.map((segment) => segment.text); + + plannedSegmentsByLocationRef.current.clear(); + pendingNextLocationRef.current = normalizedOptions.nextLocation; + // Stage the next page's canonical segments for the hidden-tab fast path. + if ( + normalizedOptions.nextLocation !== undefined + && normalizedOptions.canonicalNextSegments + && normalizedOptions.canonicalNextSegments.length > 0 + ) { + plannedSegmentsByLocationRef.current.set( + normalizeLocationKey(normalizedOptions.nextLocation), + normalizedOptions.canonicalNextSegments, + ); + } + // The walker reads these for preview fallback only; the canonical path + // attaches its own per-chunk segments, so clear them. + currentSourceUnitRef.current = null; + currentSourceContextUnitsRef.current = []; + } else { + const contextSourceUnits: CanonicalTtsSourceUnit[] = []; + if (normalizedOptions.previousText?.trim()) { + const previousLocation = normalizedOptions.previousLocation; + contextSourceUnits.push({ + sourceKey: previousLocation !== undefined + ? sourceKeyForLocation(previousLocation, currDocPage) + : `previous:${currentSourceKey}`, + text: normalizedOptions.previousText, + locator: previousLocation !== undefined + ? locatorForLocation(previousLocation, activeReaderType) + : null, + }); + } + contextSourceUnits.push(...effectiveCurrentUnits); + const sourceUnits: CanonicalTtsSourceUnit[] = [...contextSourceUnits]; + + plannedSegmentsByLocationRef.current.clear(); + pendingNextLocationRef.current = normalizedOptions.nextLocation; + const pendingPrefetches: Array<{ + location: TTSLocation; + sourceUnits: CanonicalTtsSourceUnit[]; + }> = []; + if (normalizedOptions.nextLocation !== undefined && normalizedOptions.nextText?.trim()) { + const provided = normalizedOptions.nextSourceUnits?.filter((unit) => unit.text.trim().length > 0) ?? []; pendingPrefetches.push(provided.length > 0 ? { - location: item.location, + location: normalizedOptions.nextLocation, sourceUnits: provided, } : { - location: item.location, + location: normalizedOptions.nextLocation, sourceUnits: [{ - sourceKey: sourceKeyForLocation(item.location, currDocPage), - text: item.text, - locator: locatorForLocation(item.location, activeReaderType), + sourceKey: sourceKeyForLocation(normalizedOptions.nextLocation, currDocPage), + text: normalizedOptions.nextText, + locator: locatorForLocation(normalizedOptions.nextLocation, activeReaderType), }], }); } - } - for (const item of pendingPrefetches) { - sourceUnits.push(...item.sourceUnits); - } - - const plan = planCanonicalTtsSegments(sourceUnits, { - readerType: activeReaderType, - maxBlockLength: ttsSegmentMaxBlockLength, - keyPrefix: buildSegmentKeyPrefix(documentId, activeReaderType), - enforceSourceBoundaries: activeReaderType === 'pdf' && currentUnits !== null && currentUnits.length > 0, - language: resolvedLanguage, - }); - const currentSegments = plan.segments.filter((segment) => currentSourceKeySet.has(segment.ownerSourceKey)); - const newSentences = currentSegments.map((segment) => segment.text); - - for (const item of pendingPrefetches) { - const sourceKeys = new Set(item.sourceUnits.map((unit) => unit.sourceKey)); - const planned = plan.segments.filter((segment) => sourceKeys.has(segment.ownerSourceKey)); - if (planned.length > 0) { - plannedSegmentsByLocationRef.current.set(normalizeLocationKey(item.location), planned); + if (Array.isArray(normalizedOptions.upcomingLocations)) { + for (const item of normalizedOptions.upcomingLocations) { + if (item.location === undefined || !item.text?.trim()) continue; + const provided = item.sourceUnits?.filter((unit) => unit.text.trim().length > 0) ?? []; + pendingPrefetches.push(provided.length > 0 + ? { + location: item.location, + sourceUnits: provided, + } + : { + location: item.location, + sourceUnits: [{ + sourceKey: sourceKeyForLocation(item.location, currDocPage), + text: item.text, + locator: locatorForLocation(item.location, activeReaderType), + }], + }); + } + } + for (const item of pendingPrefetches) { + sourceUnits.push(...item.sourceUnits); } - } - currentSourceUnitRef.current = effectiveCurrentUnits[0] ?? null; - currentSourceContextUnitsRef.current = contextSourceUnits; + const plan = planCanonicalTtsSegments(sourceUnits, { + readerType: activeReaderType, + maxBlockLength: ttsSegmentMaxBlockLength, + keyPrefix: buildSegmentKeyPrefix(documentId, activeReaderType), + enforceSourceBoundaries: activeReaderType === 'pdf' && currentUnits !== null && currentUnits.length > 0, + language: resolvedLanguage, + }); + currentSegments = plan.segments.filter((segment) => currentSourceKeySet.has(segment.ownerSourceKey)); + newSentences = currentSegments.map((segment) => segment.text); + + for (const item of pendingPrefetches) { + const sourceKeys = new Set(item.sourceUnits.map((unit) => unit.sourceKey)); + const planned = plan.segments.filter((segment) => sourceKeys.has(segment.ownerSourceKey)); + if (planned.length > 0) { + plannedSegmentsByLocationRef.current.set(normalizeLocationKey(item.location), planned); + } + } + + currentSourceUnitRef.current = effectiveCurrentUnits[0] ?? null; + currentSourceContextUnitsRef.current = contextSourceUnits; + } if (handleBlankSection(newSentences.join(' '))) return; @@ -1318,6 +1416,17 @@ export function TTSProvider({ children }: { children: ReactNode }): ReactElement let startIndex = 0; if (resolution.kind === 'epub-resolved') { startIndex = resolution.index; + // Cross-page jump hardening: the raw index was computed against the + // sidebar's view. On the canonical path, re-anchor to the segment whose + // per-segment charOffset matches the jump target so chapters with + // repeated text land exactly. + const jump = pendingEpubJumpRef.current; + if (useCanonicalEpub && jump && typeof jump.charOffset === 'number') { + const mapped = currentSegments.findIndex( + (segment) => segment.ownerLocator?.charOffset === jump.charOffset, + ); + if (mapped >= 0) startIndex = mapped; + } setCurrentIndex(startIndex); pendingEpubJumpRef.current = null; pendingJumpTargetRef.current = null; @@ -1329,11 +1438,21 @@ export function TTSProvider({ children }: { children: ReactNode }): ReactElement if (isEPUB) { clearPendingEpubJump(); } - startIndex = isEPUB && shouldResumePlayback - ? resolveEpubBoundaryHandoffStartIndex(currentSegments, completedEpubBoundarySegmentRef.current) - : 0; - if (startIndex > 0) { - completedEpubBoundarySegmentRef.current = null; + if (useCanonicalEpub && shouldResumePlayback) { + // Exact ordinal handoff: start at the first window segment past the + // highest ordinal already spoken in this chapter. + startIndex = resolveCanonicalStartIndex( + currentSegments, + normalizedOptions.canonicalSpine ?? null, + lastPlayedCanonicalRef.current, + ); + } else if (isEPUB && shouldResumePlayback) { + startIndex = resolveEpubBoundaryHandoffStartIndex(currentSegments, completedEpubBoundarySegmentRef.current); + if (startIndex > 0) { + completedEpubBoundarySegmentRef.current = null; + } + } else { + startIndex = 0; } setCurrentIndex(startIndex); } @@ -1524,6 +1643,9 @@ export function TTSProvider({ children }: { children: ReactNode }): ReactElement preloadGenerationSignatureRef.current = signature; clearPendingEpubJump(); bumpEpubPreloadGeneration(); + // maxBlockLength/language changes re-shape the canonical plan (new ordinals), + // so the previous handoff anchor is no longer comparable. + lastPlayedCanonicalRef.current = null; }, [ documentId, configProviderRef, @@ -2163,6 +2285,27 @@ export function TTSProvider({ children }: { children: ReactNode }): ReactElement } if (isEPUB) { completedEpubBoundarySegmentRef.current = completedEpubBoundarySegment(playbackSegment); + // Track the highest canonical ordinal spoken in this spine item so + // the next page's window can hand off at exactly ordinal + 1. + const ownerLocator = playbackSegment?.ownerLocator; + if ( + ownerLocator + && typeof ownerLocator.spineHref === 'string' + && typeof ownerLocator.spineIndex === 'number' + && typeof playbackSegment?.ordinal === 'number' + ) { + const prev = lastPlayedCanonicalRef.current; + const sameSpine = prev + && prev.spineHref === ownerLocator.spineHref + && prev.spineIndex === ownerLocator.spineIndex; + lastPlayedCanonicalRef.current = { + spineHref: ownerLocator.spineHref, + spineIndex: ownerLocator.spineIndex, + ordinal: sameSpine + ? Math.max(prev!.ordinal, playbackSegment.ordinal) + : playbackSegment.ordinal, + }; + } } if (isPlaying) { advance(); @@ -2504,32 +2647,42 @@ export function TTSProvider({ children }: { children: ReactNode }): ReactElement cfi: item.cfi, }); - const upcomingUnits: CanonicalTtsSourceUnit[] = upcomingLocationItems.map((item) => ({ - sourceKey: sourceKeyForLocation(item.cfi, currDocPage), - text: item.text, - locator: locatorForWalkerItem(item), - })); - const liveContextUnits = currentSourceContextUnitsRef.current.length > 0 - ? currentSourceContextUnitsRef.current - : (currentSourceUnitRef.current ? [currentSourceUnitRef.current] : []); - const sourceUnits: CanonicalTtsSourceUnit[] = buildWalkerPlanningSourceUnits( - liveContextUnits, - upcomingUnits, - ); + // Prefer canonical per-chunk segments attached by the walker — these + // are viewport-independent and mint identical keys/locators to what + // playback will request, so warmed audio actually hits the cache. + // Only chunks the walker couldn't canonicalize fall back to the + // legacy preview-based plan. + const fallbackItems = upcomingLocationItems.filter((item) => !item.segments?.length); + let fallbackSegments: CanonicalTtsSegment[] = []; + if (fallbackItems.length > 0) { + const upcomingUnits: CanonicalTtsSourceUnit[] = fallbackItems.map((item) => ({ + sourceKey: sourceKeyForLocation(item.cfi, currDocPage), + text: item.text, + locator: locatorForWalkerItem(item), + })); + const liveContextUnits = currentSourceContextUnitsRef.current.length > 0 + ? currentSourceContextUnitsRef.current + : (currentSourceUnitRef.current ? [currentSourceUnitRef.current] : []); + const sourceUnits: CanonicalTtsSourceUnit[] = buildWalkerPlanningSourceUnits( + liveContextUnits, + upcomingUnits, + ); + fallbackSegments = planCanonicalTtsSegments(sourceUnits, { + readerType: 'epub', + maxBlockLength: ttsSegmentMaxBlockLength, + keyPrefix: buildSegmentKeyPrefix(documentId, 'epub'), + language: resolvedLanguage, + }).segments; + } - const plan = planCanonicalTtsSegments(sourceUnits, { - readerType: 'epub', - maxBlockLength: ttsSegmentMaxBlockLength, - keyPrefix: buildSegmentKeyPrefix(documentId, 'epub'), - language: resolvedLanguage, - }); const uniqueCandidates: Array = []; const seenCandidates = new Set(); for (const item of upcomingLocationItems) { const sourceKey = sourceKeyForLocation(item.cfi, currDocPage); - const planned = plan.segments - .filter((segment) => segment.ownerSourceKey === sourceKey) - .slice(0, sentenceLookahead); + const planned = (item.segments?.length + ? item.segments + : fallbackSegments.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 ?? locatorForWalkerItem(item); @@ -2940,6 +3093,7 @@ export function TTSProvider({ children }: { children: ReactNode }): ReactElement currentSourceUnitRef.current = null; currentSourceContextUnitsRef.current = []; completedEpubBoundarySegmentRef.current = null; + lastPlayedCanonicalRef.current = null; pageFirstBlockFingerprintRef.current.clear(); setIsPlaying(false); setCurrentIndex(0); @@ -3016,11 +3170,17 @@ export function TTSProvider({ children }: { children: ReactNode }): ReactElement unlockPlaybackOnUserGesture(); if (isEPUB) { // CFI snapping makes locationKey unreliable; resolve via epoch on next setText. + // Carry the target's per-segment charOffset so the canonical window can + // re-anchor exactly (raw index is viewport-relative to the sidebar). pendingEpubJumpRef.current = { index: Math.max(0, index), epoch: epubJumpEpochRef.current, + charOffset: typeof locator?.charOffset === 'number' ? locator.charOffset : undefined, }; pendingJumpTargetRef.current = null; + // A jump breaks ordinal continuity — drop the handoff anchor so the new + // page plays from its resolved index rather than being trimmed. + lastPlayedCanonicalRef.current = null; } else { pendingJumpTargetRef.current = { locationKey: normalizeLocationKey(resolvedLocation), diff --git a/src/lib/client/epub/canonicalize-epub-segment.ts b/src/lib/client/epub/canonicalize-epub-segment.ts index 78dbef2..702b439 100644 --- a/src/lib/client/epub/canonicalize-epub-segment.ts +++ b/src/lib/client/epub/canonicalize-epub-segment.ts @@ -1,7 +1,6 @@ +import { planSpineSegments } from '@/lib/client/epub/epub-canonical-window'; import { - buildSegmentKeyPrefix, normalizeSegmentIdentityText, - planCanonicalTtsSegments, type CanonicalTtsSegment, } from '@/lib/shared/tts-segment-plan'; import type { TTSSegmentLocator } from '@/types/client'; @@ -15,6 +14,7 @@ export interface CanonicalizeEpubSegmentInput { cfi?: string; keyPrefix?: string; maxBlockLength?: number; + language?: string; } export interface CanonicalizedEpubSegment { @@ -74,30 +74,14 @@ function chooseByHintWindow( } function buildCanonicalPlan(input: Omit): CanonicalTtsSegment[] { - if (!input.spineText.trim() || !input.spineHref.trim()) { - return []; - } - - const sourceKey = `spine:${input.spineIndex}:${input.spineHref}`; - const keyPrefix = input.keyPrefix ?? buildSegmentKeyPrefix('document', 'epub'); - const plan = planCanonicalTtsSegments( - [{ - sourceKey, - text: input.spineText, - locator: { - readerType: 'epub', - spineHref: input.spineHref, - spineIndex: input.spineIndex, - charOffset: 0, - }, - }], - { - readerType: 'epub', - maxBlockLength: input.maxBlockLength, - keyPrefix, - }, - ); - return plan.segments; + return planSpineSegments({ + spineText: input.spineText, + spineHref: input.spineHref, + spineIndex: input.spineIndex, + keyPrefix: input.keyPrefix, + maxBlockLength: input.maxBlockLength, + language: input.language, + }); } function toCanonicalized( diff --git a/src/lib/client/epub/epub-canonical-window.ts b/src/lib/client/epub/epub-canonical-window.ts new file mode 100644 index 0000000..f003c57 --- /dev/null +++ b/src/lib/client/epub/epub-canonical-window.ts @@ -0,0 +1,379 @@ +import type { Book } from 'epubjs'; + +import { + findSegmentOffset, + getSpineItemPlainText, + resolveSpineFromCfi, +} from '@/lib/client/epub/spine-coordinates'; +import { + buildSegmentKeyPrefix, + normalizeSegmentIdentityText, + planCanonicalTtsSegments, + type CanonicalTtsSegment, +} from '@/lib/shared/tts-segment-plan'; +import type { TTSSegmentLocator } from '@/types/client'; + +/** + * Canonical "windowing" for EPUB TTS playback. + * + * The core invariant: a single EPUB spine item (chapter) is split into TTS + * segments **exactly once**, with the greedy block grouping fixed from the + * chapter start. Every rendered viewport page is then a contiguous *window* + * into that one canonical sequence — selected by character offset, identified + * by stable ordinal + content key. + * + * Viewport pages are **contiguous, non-overlapping** windows into that one + * canonical sequence: each segment is owned exclusively by the page where its + * start offset falls (see `selectCanonicalWindow`), exactly like PDF blocks. A + * block that straddles a page break (starts on page A, ends on page B) is the + * *same* canonical segment (same key, same ordinal) wherever it is referenced, + * but it belongs only to page A — page B's window begins at the next segment. + * This clean partition is what makes the sidebar list and manual skip + * deterministic and keeps playback from repeating a straddler on the page turn. + * The playback layer (TTSContext) additionally uses ordinal continuity to hand + * off to ordinal + 1 across the seam. See `tts-segment-plan.ts` for the planner + * and `spine-coordinates.ts` for the offset helpers this builds on. + */ + +export interface SpinePlanParams { + spineHref: string; + spineIndex: number; + keyPrefix?: string; + maxBlockLength?: number; + language?: string; +} + +export interface CanonicalWindowResult { + spineHref: string; + spineIndex: number; + /** + * Segments overlapping the viewport, with each segment's `ownerLocator` + * rewritten to carry its own per-segment `charOffset` (and the page-start + * `cfi` as a soft jump hint). Cloned — the cached plan is never mutated. + */ + segments: CanonicalTtsSegment[]; + /** Global ordinal of the first segment in `segments`. */ + windowStartOrdinal: number; + /** Global ordinal of the last segment in `segments`. */ + windowEndOrdinal: number; + /** The full, pristine per-chapter canonical plan (shared reference, do not mutate). */ + plan: CanonicalTtsSegment[]; +} + +/** + * Plan one spine item's full text into canonical segments. Pure/sync — the + * single source of truth shared by playback windowing, the sidebar, and + * persistence canonicalization so all three mint identical segment keys. + */ +export function planSpineSegments(input: { + spineText: string; + spineHref: string; + spineIndex: number; + keyPrefix?: string; + maxBlockLength?: number; + language?: string; +}): CanonicalTtsSegment[] { + if (!input.spineText.trim() || !input.spineHref.trim()) return []; + + const sourceKey = `spine:${input.spineIndex}:${input.spineHref}`; + const keyPrefix = input.keyPrefix ?? buildSegmentKeyPrefix('document', 'epub'); + const plan = planCanonicalTtsSegments( + [{ + sourceKey, + text: input.spineText, + locator: { + readerType: 'epub', + spineHref: input.spineHref, + spineIndex: input.spineIndex, + charOffset: 0, + }, + }], + { + readerType: 'epub', + maxBlockLength: input.maxBlockLength, + keyPrefix, + language: input.language, + }, + ); + return plan.segments; +} + +// Per-Book cache of fully-planned chapters. Keyed by spine identity + the +// settings that affect segmentation, so a settings change never reuses a stale +// plan. GC'd automatically with the Book instance (WeakMap). +const PLAN_CACHE = new WeakMap>(); + +const planCacheKey = (p: SpinePlanParams): string => + `${p.spineIndex}:${p.spineHref}|mbl=${p.maxBlockLength ?? ''}|lang=${p.language ?? ''}|kp=${p.keyPrefix ?? ''}`; + +/** + * Build (or reuse) the cached canonical plan for one spine item. The plan is + * computed once per (chapter, settings) so within-chapter page turns cost only + * the windowing step, not a full re-split of the chapter. + */ +export async function buildSpineCanonicalPlan( + book: Book, + params: SpinePlanParams, +): Promise { + let bucket = PLAN_CACHE.get(book); + if (!bucket) { + bucket = new Map(); + PLAN_CACHE.set(book, bucket); + } + const key = planCacheKey(params); + const cached = bucket.get(key); + if (cached) return cached; + + const spineText = await getSpineItemPlainText(book, params.spineHref); + const segments = planSpineSegments({ + spineText, + spineHref: params.spineHref, + spineIndex: params.spineIndex, + keyPrefix: params.keyPrefix, + maxBlockLength: params.maxBlockLength, + language: params.language, + }); + bucket.set(key, segments); + return segments; +} + +// Slack (in normalized chars) for snapping the window start to a segment +// boundary, absorbing tiny measurement jitter between independently measured +// adjacent-page offsets. Far smaller than a block, so a straddler (whose head +// on the previous page is substantial) is never pulled back in. +const WINDOW_START_SNAP_TOLERANCE = 8; + +/** + * Select the canonical segments that **belong to** the page spanning the + * character range [startOffset, endOffset). Returns inclusive array-index + * bounds, or null when the range falls outside the plan. + * + * A block belongs to the page where it **starts** — exactly like PDF blocks. + * This yields a clean, non-overlapping partition of the chapter across pages: + * + * - Start: the first segment that begins at/after `startOffset` (minus a small + * snap tolerance). A block straddling *in* from the previous page begins + * before `startOffset`, so it is excluded here — it belongs to the previous + * page and is never duplicated on this one. + * - End: the last segment that begins before `endOffset`. A block straddling + * *out* to the next page begins on this page, so it is kept here (and + * excluded from the next page by the same start rule). + * + * Because both the sidebar and manual skip read this exact list, navigation is + * deterministic and a block is highlighted as the same segment on every visit. + */ +export function selectCanonicalWindow( + plan: readonly CanonicalTtsSegment[], + startOffset: number, + endOffset: number, +): { startIndex: number; endIndex: number } | null { + if (plan.length === 0) return null; + + const startThreshold = startOffset - WINDOW_START_SNAP_TOLERANCE; + let startIndex = -1; + for (let i = 0; i < plan.length; i += 1) { + if (plan[i].startAnchor.offset >= startThreshold) { + startIndex = i; + break; + } + } + if (startIndex < 0) return null; // startOffset is past the end of the chapter + + let endIndex = -1; + for (let i = plan.length - 1; i >= startIndex; i -= 1) { + if (plan[i].startAnchor.offset < endOffset) { + endIndex = i; + break; + } + } + if (endIndex < startIndex) endIndex = startIndex; // always yield at least one segment + + return { startIndex, endIndex }; +} + +/** + * Re-express a segment's anchors relative to a rendered viewport so the EPUB + * highlight pipeline (resolveVisibleSegmentRange) can map it onto the page's + * text map. The chapter plan anchors a segment in *spine-global* space + * (sourceKey = `spine:…`, offset from the chapter start); highlighting needs + * viewport-local space (sourceKey = the page's rendered-map key, offset within + * the page text). Offsets are clamped to the page, so a block straddling in/out + * highlights only its visible portion. + */ +export interface ViewportAnchorContext { + /** Must equal the rendered text map's sourceKey for this page. */ + sourceKey: string; + /** Normalized char offset of the page start within the spine text. */ + baseOffset: number; + /** Normalized length of the page's visible text. */ + length: number; +} + +/** + * Clone a [startIndex, endIndex] slice of a plan, rewriting each segment's + * `ownerLocator` to a stable per-segment EPUB locator. The per-segment + * `charOffset` is load-bearing: `buildLocatorRequestKey` keys EPUB audio as + * `epub:${spineIndex}:${spineHref}:${charOffset}`, and persistence + the + * sidebar manifest sort rely on a real offset (the planner leaves it 0 for the + * whole-chapter source unit). `cfi` is attached as a non-identity jump hint. + * + * When `viewport` is provided, anchors are additionally rewritten to + * viewport-local coordinates so the page's segments can be highlighted. Omit it + * for prefetch/walker slices (not rendered on the current page). + */ +export function materializeWindowSegments( + plan: readonly CanonicalTtsSegment[], + startIndex: number, + endIndex: number, + ctx: { spineHref: string; spineIndex: number; cfi?: string }, + viewport?: ViewportAnchorContext, +): CanonicalTtsSegment[] { + const out: CanonicalTtsSegment[] = []; + const lo = Math.max(0, startIndex); + const hi = Math.min(plan.length - 1, endIndex); + const clampToViewport = (offset: number): number => + Math.max(0, Math.min(offset - viewport!.baseOffset, viewport!.length)); + for (let i = lo; i <= hi; i += 1) { + const seg = plan[i]; + const locator: TTSSegmentLocator = { + readerType: 'epub', + spineHref: ctx.spineHref, + spineIndex: ctx.spineIndex, + charOffset: Math.max(0, seg.startAnchor.offset), + }; + if (ctx.cfi) locator.cfi = ctx.cfi; + const next: CanonicalTtsSegment = { ...seg, ownerLocator: locator }; + if (viewport) { + next.startAnchor = { sourceKey: viewport.sourceKey, offset: clampToViewport(seg.startAnchor.offset) }; + next.endAnchor = { sourceKey: viewport.sourceKey, offset: clampToViewport(seg.endAnchor.offset) }; + // Keep ownerSourceKey in lock-step with the rewritten anchors: the word + // highlighter (resolveAlignmentWordSourceRange) requires + // startAnchor.sourceKey === ownerSourceKey to treat the segment as + // anchored in the rendered page. + next.ownerSourceKey = viewport.sourceKey; + } + out.push(next); + } + return out; +} + +const buildResult = ( + plan: CanonicalTtsSegment[], + range: { startIndex: number; endIndex: number }, + ctx: { spineHref: string; spineIndex: number; cfi?: string }, + viewport?: ViewportAnchorContext, +): CanonicalWindowResult | null => { + const segments = materializeWindowSegments(plan, range.startIndex, range.endIndex, ctx, viewport); + if (segments.length === 0) return null; + return { + spineHref: ctx.spineHref, + spineIndex: ctx.spineIndex, + segments, + windowStartOrdinal: plan[range.startIndex].ordinal, + windowEndOrdinal: plan[range.endIndex].ordinal, + plan, + }; +}; + +export interface BuildEpubCanonicalWindowOptions { + startCfi: string; + viewportText: string; + keyPrefix?: string; + maxBlockLength?: number; + language?: string; + /** + * Rendered text map sourceKey for this page. When provided, the returned + * segments' anchors are rewritten to viewport-local coordinates so they can + * be highlighted (resolveVisibleSegmentRange matches on this sourceKey). + */ + viewportAnchorSourceKey?: string; +} + +/** + * Foreground path: build the canonical window for the currently rendered page + * from its start CFI + visible text. Returns null when the page text can't be + * located in the spine item (footnotes, nav docs, image-only pages) so the + * caller can fall back to the legacy preview-based plan. + */ +export async function buildEpubCanonicalWindow( + book: Book | null | undefined, + options: BuildEpubCanonicalWindowOptions, +): Promise { + if (!book?.isOpen) return null; + if (!options.viewportText.trim()) return null; + + const spine = resolveSpineFromCfi(book, options.startCfi); + if (!spine) return null; + + const plan = await buildSpineCanonicalPlan(book, { + spineHref: spine.href, + spineIndex: spine.index, + keyPrefix: options.keyPrefix, + maxBlockLength: options.maxBlockLength, + language: options.language, + }); + if (plan.length === 0) return null; + + const spineText = await getSpineItemPlainText(book, spine.href); + // Explicit -1 detection — do NOT use buildEpubChunkAnchor here, which masks a + // miss to offset 0 and would silently window from the chapter start. + const startOffset = findSegmentOffset(spineText, options.viewportText, 0); + if (startOffset < 0) return null; + const viewportLength = normalizeSegmentIdentityText(options.viewportText).length; + const endOffset = startOffset + viewportLength; + + const range = selectCanonicalWindow(plan, startOffset, endOffset); + if (!range) return null; + const viewport: ViewportAnchorContext | undefined = options.viewportAnchorSourceKey + ? { sourceKey: options.viewportAnchorSourceKey, baseOffset: startOffset, length: viewportLength } + : undefined; + return buildResult(plan, range, { + spineHref: spine.href, + spineIndex: spine.index, + cfi: options.startCfi, + }, viewport); +} + +export interface BuildEpubCanonicalWindowFromChunkOptions { + spineHref: string; + spineIndex: number; + /** Chunk start offset in normalized character space (from the walker). */ + chunkOffset: number; + text: string; + cfi?: string; + keyPrefix?: string; + maxBlockLength?: number; + language?: string; +} + +/** + * Prefetch/walker path: build a canonical window from already-resolved spine + * coordinates (the walker reports spineHref/spineIndex/chunkOffset/text), so no + * CFI resolution or range extraction is needed. + */ +export async function buildEpubCanonicalWindowFromChunk( + book: Book | null | undefined, + options: BuildEpubCanonicalWindowFromChunkOptions, +): Promise { + if (!book?.isOpen) return null; + if (!options.text.trim()) return null; + + const plan = await buildSpineCanonicalPlan(book, { + spineHref: options.spineHref, + spineIndex: options.spineIndex, + keyPrefix: options.keyPrefix, + maxBlockLength: options.maxBlockLength, + language: options.language, + }); + if (plan.length === 0) return null; + + const startOffset = Math.max(0, options.chunkOffset); + const endOffset = startOffset + normalizeSegmentIdentityText(options.text).length; + const range = selectCanonicalWindow(plan, startOffset, endOffset); + if (!range) return null; + return buildResult(plan, range, { + spineHref: options.spineHref, + spineIndex: options.spineIndex, + cfi: options.cfi, + }); +} diff --git a/src/lib/client/epub/rendered-location-walker.ts b/src/lib/client/epub/rendered-location-walker.ts index 9dbf024..8d44735 100644 --- a/src/lib/client/epub/rendered-location-walker.ts +++ b/src/lib/client/epub/rendered-location-walker.ts @@ -6,6 +6,7 @@ import { invalidateSpinePlainTextCache, } from '@/lib/client/epub/spine-coordinates'; import { buildWalkerThemeRules, type WalkerThemeSnapshot } from '@/lib/client/epub/walker-theme'; +import type { CanonicalTtsSegment } from '@/lib/shared/tts-segment-plan'; export interface RenderedLocationWalkItem { /** Page-start CFI from the rendition. Retained as a soft jump hint only. */ @@ -22,6 +23,13 @@ export interface RenderedLocationWalkItem { * viewports, so segments can be anchored to this base. */ chunkOffset: number; + /** + * Canonical segments for this chunk, windowed from the chapter's canonical + * plan. Attached after the raw walk by `walkUpcomingRenderedLocations` (which + * holds the live Book). Present means prefetch can use viewport-independent + * segments that mint identical keys to playback; absent → preview fallback. + */ + segments?: CanonicalTtsSegment[]; } export interface RenderedLocationWalkRequest { diff --git a/src/types/tts.ts b/src/types/tts.ts index a3f76d8..c5f42d5 100644 --- a/src/types/tts.ts +++ b/src/types/tts.ts @@ -41,6 +41,13 @@ interface EpubRenderedLocationWalkItem { * spine item's plain text. Stable across viewports. */ chunkOffset: number; + /** + * Canonical segments for this chunk, windowed from the chapter's canonical + * plan and attached after the raw walk. Present → prefetch uses + * viewport-independent segments with identical keys to playback; absent → + * preview-based fallback planning. + */ + segments?: CanonicalTtsSegment[]; } export type EpubRenderedLocationWalker = ( diff --git a/tests/unit/epub-canonical-window.vitest.spec.ts b/tests/unit/epub-canonical-window.vitest.spec.ts new file mode 100644 index 0000000..54ac89d --- /dev/null +++ b/tests/unit/epub-canonical-window.vitest.spec.ts @@ -0,0 +1,311 @@ +import { describe, expect, test } from 'vitest'; +import type { Book } from 'epubjs'; + +import { + buildEpubCanonicalWindow, + buildEpubCanonicalWindowFromChunk, + buildSpineCanonicalPlan, + materializeWindowSegments, + planSpineSegments, + selectCanonicalWindow, +} from '../../src/lib/client/epub/epub-canonical-window'; +import type { CanonicalTtsSegment } from '../../src/lib/shared/tts-segment-plan'; + +const SPINE_HREF = 'OEBPS/ch01.xhtml'; +const SPINE_INDEX = 1; +const KEY_PREFIX = 'doc-1:epub:v1'; +const MAX_BLOCK = 80; + +// A chapter long enough that an ~80-char block grouping yields many segments, +// so a page break can be placed inside one of them. +const SENTENCES = [ + 'The star was particularly bright when the station lights switched off for cycle night.', + 'After losing his staring match, the night janitor muttered and walked on alone.', + 'You might have called it aqua, or perhaps a faded green under the old glass.', + 'A titch too purple for hot pink, it was still impossible to ignore at noon.', + 'Needing no pole or wire to hold them aloft, the banners drifted above the plaza.', + 'He would have been confused to hear that this was considered a calm evening here.', + 'The lift doors parted onto a corridor that smelled faintly of ozone and rain.', + 'Somewhere below, a generator coughed twice and then settled into its low hum.', +]; +const SPINE_TEXT = SENTENCES.join('\n'); + +function makeFakeBook(text: string): Book { + const section = { + index: SPINE_INDEX, + href: SPINE_HREF, + cfiBase: '/6/4', + load: async () => ({ + querySelector: (sel: string) => (sel === 'body' ? { textContent: text } : null), + textContent: text, + }), + unload: () => {}, + }; + const get = (target: unknown) => { + if (typeof target === 'number') return target === SPINE_INDEX ? section : null; + if (typeof target === 'string') { + if (target === SPINE_HREF || target.includes(SPINE_HREF)) return section; + if (target.includes(section.cfiBase)) return section; + } + return null; + }; + return { + isOpen: true, + spine: { get, spineItems: [section] }, + load: () => Promise.resolve(undefined), + } as unknown as Book; +} + +const plan = (): CanonicalTtsSegment[] => + planSpineSegments({ + spineText: SPINE_TEXT, + spineHref: SPINE_HREF, + spineIndex: SPINE_INDEX, + keyPrefix: KEY_PREFIX, + maxBlockLength: MAX_BLOCK, + }); + +describe('planSpineSegments', () => { + test('produces sequential ordinals and stable keys', () => { + const segments = plan(); + expect(segments.length).toBeGreaterThanOrEqual(6); + segments.forEach((segment, i) => { + expect(segment.ordinal).toBe(i); + expect(segment.key.startsWith(`${KEY_PREFIX}:`)).toBe(true); + // Within-chapter single source unit → never a "source boundary". + expect(segment.spansSourceBoundary).toBe(false); + }); + }); +}); + +describe('selectCanonicalWindow — clean partition', () => { + test('a block straddling a page break belongs only to the page where it starts', () => { + const segments = plan(); + // Pick a block in the middle and place the page break inside it (the + // viewport for page B begins partway through this straddling block). + const boundary = segments[Math.floor(segments.length / 2)]; + const splitOffset = Math.floor((boundary.startAnchor.offset + boundary.endAnchor.offset) / 2); + const chapterEnd = segments[segments.length - 1].endAnchor.offset + 1; + + const pageA = selectCanonicalWindow(segments, 0, splitOffset); + const pageB = selectCanonicalWindow(segments, splitOffset, chapterEnd); + expect(pageA).not.toBeNull(); + expect(pageB).not.toBeNull(); + + // The straddler is the LAST segment of page A and is NOT on page B. + expect(pageA!.endIndex).toBe(boundary.ordinal); + expect(pageB!.startIndex).toBe(boundary.ordinal + 1); + }); + + test('page B starts exactly at boundary + 1 — no overlap, no gap (deterministic list)', () => { + const segments = plan(); + const boundary = segments[Math.floor(segments.length / 2)]; + const splitOffset = Math.floor((boundary.startAnchor.offset + boundary.endAnchor.offset) / 2); + const chapterEnd = segments[segments.length - 1].endAnchor.offset + 1; + + const pageA = selectCanonicalWindow(segments, 0, splitOffset)!; + const pageB = selectCanonicalWindow(segments, splitOffset, chapterEnd)!; + + // The two pages partition the ordinals contiguously: …A.end | B.start… + expect(pageB.startIndex).toBe(pageA.endIndex + 1); + + // The sidebar/manual-skip list for page B never contains a page-A segment. + const windowB = segments.slice(pageB.startIndex, pageB.endIndex + 1); + expect(windowB.every((s) => s.ordinal > boundary.ordinal)).toBe(true); + }); + + test('clean break (split between blocks) yields adjacent, non-overlapping ordinals', () => { + const segments = plan(); + const before = segments[2]; + const after = segments[3]; + // Split exactly at the start of `after` (a clean sentence boundary). + const splitOffset = after.startAnchor.offset; + + const pageA = selectCanonicalWindow(segments, 0, splitOffset)!; + const pageB = selectCanonicalWindow(segments, splitOffset, segments[segments.length - 1].endAnchor.offset + 1)!; + + expect(pageA.endIndex).toBe(before.ordinal); + expect(pageB.startIndex).toBe(after.ordinal); + expect(pageB.startIndex).toBe(pageA.endIndex + 1); + }); + + test('is idempotent — identical offsets give identical keys/ordinals (resize safety)', () => { + const segments = plan(); + const a = selectCanonicalWindow(segments, 120, 360)!; + const b = selectCanonicalWindow(segments, 120, 360)!; + expect(a).toEqual(b); + }); + + test('a block keeps its identity when the page break moves (resize / different sizes)', () => { + const segments = plan(); + const s = segments[Math.floor(segments.length / 2)]; + const chapterEnd = segments[segments.length - 1].endAnchor.offset + 1; + + // Narrow viewport: the break lands right after `s`, so `s` is the LAST + // segment on its page (page base starts at the chapter start). + const narrow = selectCanonicalWindow(segments, 0, s.startAnchor.offset + 1)!; + const narrowSegs = materializeWindowSegments(segments, narrow.startIndex, narrow.endIndex, + { spineHref: SPINE_HREF, spineIndex: SPINE_INDEX, cfi: 'cfiA' }, + { sourceKey: 'pageA', baseOffset: 0, length: s.endAnchor.offset }); + const sInNarrow = narrowSegs[narrowSegs.length - 1]; + + // Wider/shifted viewport: the break lands right before `s`, so the SAME + // block is now the FIRST segment on a later page (different base offset). + const wide = selectCanonicalWindow(segments, s.startAnchor.offset, chapterEnd)!; + const wideSegs = materializeWindowSegments(segments, wide.startIndex, wide.endIndex, + { spineHref: SPINE_HREF, spineIndex: SPINE_INDEX, cfi: 'cfiB' }, + { sourceKey: 'pageB', baseOffset: s.startAnchor.offset, length: chapterEnd - s.startAnchor.offset }); + const sInWide = wideSegs[0]; + + // Identity is viewport-independent: same key, same ordinal, same audio + // locator charOffset — so audio cache, sidebar, and persistence all agree + // no matter how the chapter re-paginates. + expect(sInWide.key).toBe(sInNarrow.key); + expect(sInWide.ordinal).toBe(sInNarrow.ordinal); + expect(sInWide.ownerLocator?.charOffset).toBe(sInNarrow.ownerLocator?.charOffset); + expect(sInWide.ownerLocator?.charOffset).toBe(s.startAnchor.offset); + + // …but the highlight anchors are viewport-local, so they adapt to each page. + expect(sInWide.startAnchor.sourceKey).toBe('pageB'); + expect(sInNarrow.startAnchor.sourceKey).toBe('pageA'); + expect(sInWide.startAnchor.offset).toBe(0); // first on its page → left edge + }); + + test('returns null when the start offset is past the end of the chapter', () => { + const segments = plan(); + const past = segments[segments.length - 1].endAnchor.offset + 1000; + expect(selectCanonicalWindow(segments, past, past + 10)).toBeNull(); + }); +}); + +describe('materializeWindowSegments', () => { + test('rewrites each ownerLocator with its own charOffset + cfi hint', () => { + const segments = plan(); + const out = materializeWindowSegments(segments, 1, 3, { + spineHref: SPINE_HREF, + spineIndex: SPINE_INDEX, + cfi: 'epubcfi(/6/4!/0)', + }); + expect(out).toHaveLength(3); + out.forEach((seg, i) => { + const original = segments[i + 1]; + expect(seg.key).toBe(original.key); + expect(seg.ordinal).toBe(original.ordinal); + expect(seg.ownerLocator?.readerType).toBe('epub'); + expect(seg.ownerLocator?.spineHref).toBe(SPINE_HREF); + expect(seg.ownerLocator?.charOffset).toBe(original.startAnchor.offset); + expect(seg.ownerLocator?.cfi).toBe('epubcfi(/6/4!/0)'); + }); + // Does not mutate the source plan's locators. + expect(segments[1].ownerLocator?.charOffset).toBe(0); + }); +}); + +describe('buildSpineCanonicalPlan (cached)', () => { + test('returns the same plan reference for the same params and matches planSpineSegments', async () => { + const book = makeFakeBook(SPINE_TEXT); // fresh Book → empty plan cache + const first = await buildSpineCanonicalPlan(book, { + spineHref: SPINE_HREF, spineIndex: SPINE_INDEX, keyPrefix: KEY_PREFIX, maxBlockLength: MAX_BLOCK, + }); + const second = await buildSpineCanonicalPlan(book, { + spineHref: SPINE_HREF, spineIndex: SPINE_INDEX, keyPrefix: KEY_PREFIX, maxBlockLength: MAX_BLOCK, + }); + expect(second).toBe(first); // cache hit → identical reference + expect(first.map((s) => s.key)).toEqual(plan().map((s) => s.key)); + }); + + test('cache is language-aware: a different language is a cache miss', async () => { + const book = makeFakeBook(SPINE_TEXT); // fresh Book → empty plan cache + const base = { + spineHref: SPINE_HREF, spineIndex: SPINE_INDEX, keyPrefix: KEY_PREFIX, maxBlockLength: MAX_BLOCK, + }; + const en = await buildSpineCanonicalPlan(book, { ...base, language: 'en' }); + const ja = await buildSpineCanonicalPlan(book, { ...base, language: 'ja' }); + // Distinct language → distinct cache key → distinct reference (no stale + // reuse across languages). Same language returns the cached reference. + expect(ja).not.toBe(en); + expect(await buildSpineCanonicalPlan(book, { ...base, language: 'en' })).toBe(en); + }); +}); + +describe('buildEpubCanonicalWindowFromChunk', () => { + test('windows a chunk to canonical segments with playback-identical keys', async () => { + const book = makeFakeBook(SPINE_TEXT); + const segments = plan(); + const target = segments[3]; + const window = await buildEpubCanonicalWindowFromChunk(book, { + spineHref: SPINE_HREF, + spineIndex: SPINE_INDEX, + chunkOffset: target.startAnchor.offset, + text: target.text, + cfi: 'epubcfi(/6/4!/8)', + keyPrefix: KEY_PREFIX, + maxBlockLength: MAX_BLOCK, + }); + expect(window).not.toBeNull(); + expect(window!.segments[0].key).toBe(target.key); + expect(window!.segments[0].ownerLocator?.charOffset).toBe(target.startAnchor.offset); + expect(window!.windowStartOrdinal).toBe(target.ordinal); + }); +}); + +describe('buildEpubCanonicalWindow (CFI path)', () => { + test('windows the visible viewport text into the chapter plan', async () => { + const book = makeFakeBook(SPINE_TEXT); + const segments = plan(); + // Viewport shows two consecutive sentences. + const viewportText = `${SENTENCES[2]} ${SENTENCES[3]}`; + const window = await buildEpubCanonicalWindow(book, { + startCfi: 'epubcfi(/6/4!/0)', + viewportText, + keyPrefix: KEY_PREFIX, + maxBlockLength: MAX_BLOCK, + }); + expect(window).not.toBeNull(); + expect(window!.spineHref).toBe(SPINE_HREF); + // The window's keys are all drawn from the single chapter plan. + const planKeys = new Set(segments.map((s) => s.key)); + window!.segments.forEach((s) => expect(planKeys.has(s.key)).toBe(true)); + }); + + test('rewrites anchors to viewport-local coordinates for highlighting', async () => { + const book = makeFakeBook(SPINE_TEXT); + const viewportText = `${SENTENCES[2]} ${SENTENCES[3]}`; + const pageKey = 'page-sourcekey'; + const window = await buildEpubCanonicalWindow(book, { + startCfi: 'epubcfi(/6/4!/0)', + viewportText, + keyPrefix: KEY_PREFIX, + maxBlockLength: MAX_BLOCK, + viewportAnchorSourceKey: pageKey, + }); + expect(window).not.toBeNull(); + const viewportLen = viewportText.replace(/\s+/g, ' ').trim().length; + window!.segments.forEach((seg) => { + // Anchors point at the rendered map, not the spine. + expect(seg.startAnchor.sourceKey).toBe(pageKey); + expect(seg.endAnchor.sourceKey).toBe(pageKey); + // ownerSourceKey is kept in lock-step so the word highlighter's + // startAnchor.sourceKey === ownerSourceKey guard passes. + expect(seg.ownerSourceKey).toBe(pageKey); + // Offsets are viewport-local and clamped to the page. + expect(seg.startAnchor.offset).toBeGreaterThanOrEqual(0); + expect(seg.endAnchor.offset).toBeLessThanOrEqual(viewportLen); + // ownerLocator (audio identity) stays spine-based. + expect(seg.ownerLocator?.spineHref).toBe(SPINE_HREF); + }); + // First visible segment starts at the page's left edge. + expect(window!.segments[0].startAnchor.offset).toBe(0); + }); + + test('returns null when viewport text is not indexable in the spine (fallback signal)', async () => { + const book = makeFakeBook(SPINE_TEXT); + const window = await buildEpubCanonicalWindow(book, { + startCfi: 'epubcfi(/6/4!/0)', + viewportText: 'Text that does not appear anywhere in this chapter at all.', + keyPrefix: KEY_PREFIX, + maxBlockLength: MAX_BLOCK, + }); + expect(window).toBeNull(); + }); +});