From 4a985b3bf1a4ba60beae6c0e18cd519bd2688711 Mon Sep 17 00:00:00 2001 From: Richard R Date: Sat, 4 Apr 2026 14:50:34 -0600 Subject: [PATCH] feat(tts): add continuation loop guard and block fingerprinting Introduce safeguards for continuation handling in TTS flow to prevent looping and over-carrying text. Add constants to limit carried characters and words, and loop-guard thresholds. Implement normalizeBlockFingerprint to create compact fingerprints of page-first blocks and store them in pageFirstBlockFingerprintRef. Use fingerprint comparison and progress threshold to skip to next page when a loop is detected. Cap continuation slices by characters and words and ensure carried text matches the returned addition. test(ui): make settings save button click more robust In tests/helpers.ts wait for the Settings Save button to become visible (with a short timeout) before attempting to open Settings, and click the Settings button with force to avoid intermittent pointer-interception failures. Improve stability of setupTest by handling enter transitions and overlays. --- src/contexts/TTSContext.tsx | 61 +++++++++++++++++++++++++++++++++++-- tests/helpers.ts | 11 +++++-- 2 files changed, 67 insertions(+), 5 deletions(-) diff --git a/src/contexts/TTSContext.tsx b/src/contexts/TTSContext.tsx index 940367d..9d5f1f6 100644 --- a/src/contexts/TTSContext.tsx +++ b/src/contexts/TTSContext.tsx @@ -100,6 +100,10 @@ interface SetTextOptions { } const CONTINUATION_LOOKAHEAD = 600; +const MAX_CONTINUATION_CARRY_CHARS = 220; +const MAX_CONTINUATION_CARRY_WORDS = 40; +const LOOP_GUARD_MIN_INDEX = 2; +const LOOP_GUARD_MIN_PROGRESS = 0.6; const SENTENCE_ENDING = /[.?!…]["'”’)\]]*\s*$/; const wordHighlightFeatureEnabled = process.env.NEXT_PUBLIC_ENABLE_WORD_HIGHLIGHT?.toLowerCase() !== 'false'; @@ -111,6 +115,15 @@ const SILENT_WAV_DATA_URI = const normalizeLocationKey = (location: TTSLocation) => typeof location === 'number' ? `num:${location}` : `str:${location}`; +const normalizeBlockFingerprint = (text: string): string => { + const normalized = preprocessSentenceForAudio(text) + .toLowerCase() + .replace(/[^a-z0-9\s]/g, ' ') + .replace(/\s+/g, ' ') + .trim(); + return normalized.slice(0, 200); +}; + const isWhitespaceChar = (char: string) => /\s/.test(char); const skipWhitespace = (source: string, start: number) => { @@ -221,7 +234,10 @@ const extractContinuationSlice = (nextText: string): TTSSmartMergeResult | null } const rawSlice = snippet.slice(0, boundaryIndex); - const addition = rawSlice.trim(); + const charsCappedSlice = rawSlice.slice(0, MAX_CONTINUATION_CARRY_CHARS); + const words = charsCappedSlice.trim().split(/\s+/).filter(Boolean); + const wordCapped = words.slice(0, MAX_CONTINUATION_CARRY_WORDS).join(' '); + const addition = wordCapped.trim(); if (!addition) { return null; @@ -229,7 +245,7 @@ const extractContinuationSlice = (nextText: string): TTSSmartMergeResult | null return { text: addition, - carried: rawSlice, + carried: addition, }; }; @@ -388,6 +404,7 @@ export function TTSProvider({ children }: { children: ReactNode }): ReactElement const epubContinuationRef = useRef(null); const pageTurnEstimateRef = useRef(null); const pageTurnTimeoutRef = useRef | null>(null); + const pageFirstBlockFingerprintRef = useRef>(new Map()); const sentenceAlignmentCacheRef = useRef>(new Map()); const [currentSentenceAlignment, setCurrentSentenceAlignment] = useState(); const [currentWordIndex, setCurrentWordIndex] = useState(null); @@ -587,6 +604,35 @@ export function TTSProvider({ children }: { children: ReactNode }): ReactElement // Handle within current page bounds if (nextIndex < sentences.length && nextIndex >= 0) { + if ( + !isEPUB && + !backwards && + currDocPages !== undefined && + currDocPageNumber < currDocPages && + nextIndex >= LOOP_GUARD_MIN_INDEX && + sentences.length > 0 + ) { + const locationKey = normalizeLocationKey(currDocPageNumber); + const cachedFirstFingerprint = pageFirstBlockFingerprintRef.current.get(locationKey) + ?? normalizeBlockFingerprint(sentences[0] || ''); + const nextFingerprint = normalizeBlockFingerprint(sentences[nextIndex] || ''); + const progress = sentences.length > 1 ? nextIndex / (sentences.length - 1) : 0; + + if ( + cachedFirstFingerprint && + nextFingerprint && + cachedFirstFingerprint === nextFingerprint && + progress >= LOOP_GUARD_MIN_PROGRESS + ) { + const targetLocation = currDocPageNumber + 1; + prefetchedLocationTextRef.current.delete(normalizeLocationKey(targetLocation)); + pendingNextLocationRef.current = targetLocation; + continuationCarryRef.current.delete(normalizeLocationKey(targetLocation)); + skipToLocation(targetLocation); + return; + } + } + setCurrentIndex(nextIndex); return; } @@ -769,6 +815,16 @@ export function TTSProvider({ children }: { children: ReactNode }): ReactElement return; } + if (!isEPUB && typeof normalizedOptions.location === 'number') { + const firstFingerprint = normalizeBlockFingerprint(newSentences[0] || ''); + if (firstFingerprint) { + pageFirstBlockFingerprintRef.current.set( + normalizeLocationKey(normalizedOptions.location), + firstFingerprint + ); + } + } + // Set all state updates in a predictable order setSentences(newSentences); @@ -1667,6 +1723,7 @@ export function TTSProvider({ children }: { children: ReactNode }): ReactElement locationChangeHandlerRef.current = null; epubContinuationRef.current = null; continuationCarryRef.current.clear(); + pageFirstBlockFingerprintRef.current.clear(); setIsPlaying(false); setCurrentIndex(0); setSentences([]); diff --git a/tests/helpers.ts b/tests/helpers.ts index f20f7ff..e2c8606 100644 --- a/tests/helpers.ts +++ b/tests/helpers.ts @@ -234,12 +234,17 @@ export async function setupTest(page: Page, testInfo?: TestInfo) { // privacy modal, so open Settings explicitly if onboarding did not auto-open. const settingsDialog = page.getByRole('dialog', { name: 'Settings' }); const saveBtn = settingsDialog.getByRole('button', { name: 'Save' }); - if (!(await saveBtn.isVisible().catch(() => false))) { + + // On some local runs, Settings opens automatically but the Save button is not yet + // visible during enter transition. Prefer waiting for it before trying to click. + await saveBtn.waitFor({ state: 'visible', timeout: 2500 }).catch(async () => { const settingsBtn = page.getByRole('button', { name: 'Settings' }); if (await settingsBtn.isVisible().catch(() => false)) { - await settingsBtn.click(); + // Force avoids occasional pointer interception by in-flight dialog overlays. + await settingsBtn.click({ force: true }); } - } + }); + await expect(saveBtn).toBeVisible({ timeout: 10000 }); // SettingsModal can briefly disable Save while it mirrors a custom model into the input field. await expect(saveBtn).toBeEnabled({ timeout: 15000 });