refactor(tts,epub,locator): introduce canonical segment keys and locator comparison utilities
Refactor TTS segment identification to support canonical segment keys, decoupling segment IDs from locator and index for more robust deduplication and handoff. Add `segmentKey` to segment types, manifest, and ensure route. Implement natural sorting for EPUB CFI locations and segment locators using new comparison utilities. Update sidebar and manifest helpers to use locator-aware sorting. Add foundational modules for EPUB word highlighting, TTS segment planning, and EPUB handoff logic. Expand types and tests to cover new canonical segment and locator behaviors. BREAKING CHANGE: TTS segment IDs now support canonical segment keys; APIs and manifest consumers must handle `segmentKey` and updated locator comparison logic.
This commit is contained in:
parent
265e1cb56a
commit
e8d4c11434
19 changed files with 1900 additions and 835 deletions
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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<EPUBContextType | undefined>(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<string | null>(null);
|
||||
const currentWordHighlightCfi = useRef<string | null>(null);
|
||||
const renderedTextMapsRef = useRef<EpubRenderedTextMap[]>([]);
|
||||
const wordHighlightMapCacheRef = useRef<EpubWordHighlightMapCache | null>(null);
|
||||
const renderedLocationCloneManagerRef = useRef<EpubRenderedLocationCloneManager>(
|
||||
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<string> => {
|
||||
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<number>(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<number>(n + 1).fill(Number.POSITIVE_INFINITY)
|
||||
);
|
||||
const bt: number[][] = Array.from({ length: m + 1 }, () =>
|
||||
new Array<number>(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,
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
}
|
||||
|
|
|
|||
114
src/lib/shared/epub-word-highlight.ts
Normal file
114
src/lib/shared/epub-word-highlight.ts
Normal file
|
|
@ -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<number>(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<number>(n + 1).fill(0));
|
||||
const bt: number[][] = Array.from({ length: m + 1 }, () => new Array<number>(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('::');
|
||||
96
src/lib/shared/tts-epub-handoff.ts
Normal file
96
src/lib/shared/tts-epub-handoff.ts
Normal file
|
|
@ -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' };
|
||||
};
|
||||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
369
src/lib/shared/tts-segment-plan.ts
Normal file
369
src/lib/shared/tts-segment-plan.ts
Normal file
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
60
tests/unit/epub-word-highlight.spec.ts
Normal file
60
tests/unit/epub-word-highlight.spec.ts
Normal file
|
|
@ -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]);
|
||||
});
|
||||
});
|
||||
115
tests/unit/tts-epub-handoff.spec.ts
Normal file
115
tests/unit/tts-epub-handoff.spec.ts
Normal file
|
|
@ -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> = {},
|
||||
): 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' });
|
||||
});
|
||||
});
|
||||
185
tests/unit/tts-segment-plan-leading.spec.ts
Normal file
185
tests/unit/tts-segment-plan-leading.spec.ts
Normal file
|
|
@ -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');
|
||||
});
|
||||
});
|
||||
167
tests/unit/tts-segment-plan.spec.ts
Normal file
167
tests/unit/tts-segment-plan.spec.ts
Normal file
|
|
@ -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');
|
||||
});
|
||||
});
|
||||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
|
|
|
|||
Loading…
Reference in a new issue