From 2d35beff0cae5d4594c1aeb190e04c4bfecd2238 Mon Sep 17 00:00:00 2001 From: bizzkoot Date: Sun, 2 Aug 2026 10:11:28 +0800 Subject: [PATCH 1/9] feat(tts): add declarative text cleanup pipeline for TTS audio Implements a length-preserving, settings-driven text cleanup pipeline so users can strip anti-scraper watermarks and fix LN name pronunciations before text reaches the native Android TTS engine, across all playback modes and paths (initial queue, WebView DOM refills, fallback speak). Addresses the ISSUE #17 root cause verified in PR.md: the proposed window.tts.getTextNodes() synchronous hook was rejected because that mechanism does not exist and cannot affect the RN-parsed initial audio queue, which is the only source in the default background mode. The fix instead applies a declarative pipeline in the React Native layer. Pipeline (per paragraph): 1. Optional NFD unicode normalization + combining-mark strip 2. Ordered find/replace rules (literal or regex + flags, strip via empty replacement) 3. Phonetic dictionary (whole-word, unicode-aware boundaries) Key properties: - Length-preserving: never drops/merges paragraphs, keeping the RN <-> WebView paragraph index and utterance-ID contract intact - No eval/Function of user code in RN; declarative rules only - No hardcoded site regexes; everything is user-configurable - Invalid regexes are skipped without crashing TTS - Missing settings on legacy installs degrade to a no-op Wiring covers every audio-feeding point (verified by audit): - useTTSController.ts: 7 RN-parse sites + 'tts-queue' refill + 'speak' fallback (both speak paths) - useTTSUtilities.ts: restart/seek/drift path - WebViewReader.tsx: live-settings-change restart path Settings UI: AccessibilityTab (global) and ReaderTTSTab quick access, with a new TtsTextCleanupModal editor (rules + phonetic dictionary). Tests: 20 new unit tests for the cleanup engine; all affected TTS suites updated; full suite 1216 passing (baseline 1196). --- src/hooks/persisted/useSettings.ts | 14 + .../ReaderBottomSheet/ReaderTTSTab.tsx | 31 + .../reader/components/WebViewReader.tsx | 10 +- .../WebViewReader.eventHandlers.test.tsx | 2 + .../WebViewReader.integration.test.tsx | 2 + .../useTTSController.integration.test.ts | 2 + .../useTTSController.mediaNav.test.ts | 2 + .../__tests__/useTTSProgressSync.test.ts | 2 + .../hooks/__tests__/useTTSUtilities.test.ts | 41 +- src/screens/reader/hooks/useTTSController.ts | 65 ++- src/screens/reader/hooks/useTTSUtilities.ts | 18 +- .../Modals/TtsTextCleanupModal.tsx | 551 ++++++++++++++++++ .../tabs/AccessibilityTab.tsx | 38 ++ src/utils/__tests__/ttsTextCleanup.test.ts | 207 +++++++ src/utils/htmlParagraphExtractor.ts | 235 ++++++++ 15 files changed, 1199 insertions(+), 21 deletions(-) create mode 100644 src/screens/settings/SettingsReaderScreen/Modals/TtsTextCleanupModal.tsx create mode 100644 src/utils/__tests__/ttsTextCleanup.test.ts diff --git a/src/hooks/persisted/useSettings.ts b/src/hooks/persisted/useSettings.ts index 117462b6a2..13e46a43c8 100644 --- a/src/hooks/persisted/useSettings.ts +++ b/src/hooks/persisted/useSettings.ts @@ -7,6 +7,10 @@ import { useMMKVObject } from 'react-native-mmkv'; import { Voice } from 'expo-speech'; import { clampUIScale } from '@theme/scaling'; import { DoHProvider } from '@services/network/DoHManager'; +import { + TtsTextCleanupSettings, + DEFAULT_TTS_CLEANUP_SETTINGS, +} from '@utils/htmlParagraphExtractor'; export const APP_SETTINGS = 'APP_SETTINGS'; export const BROWSE_SETTINGS = 'BROWSE_SETTINGS'; @@ -243,6 +247,15 @@ export interface ChapterGeneralSettings { * Show discoverability hint toast for TTS floating button gestures */ ttsShowGestureHints: boolean; + /** + * TTS text cleanup pipeline applied to every paragraph before it reaches + * the native TTS engine. Includes ordered find/replace rules, a phonetic + * pronunciation dictionary, and optional Unicode normalization. + * Applied across ALL playback modes and paths (initial queue, WebView + * refills, fallback single-speak). Length-preserving: never drops or merges + * paragraphs, so the RN <-> WebView paragraph index contract stays intact. + */ + ttsTextCleanup: TtsTextCleanupSettings; } export interface ReaderTheme { @@ -381,6 +394,7 @@ export const initialChapterGeneralSettings: ChapterGeneralSettings = { continuousScrollTransitionThreshold: 15, continuousScrollStitchThreshold: 90, ttsShowGestureHints: true, + ttsTextCleanup: DEFAULT_TTS_CLEANUP_SETTINGS, }; export const initialChapterReaderSettings: ChapterReaderSettings = { diff --git a/src/screens/reader/components/ReaderBottomSheet/ReaderTTSTab.tsx b/src/screens/reader/components/ReaderBottomSheet/ReaderTTSTab.tsx index fa7eb2c5fe..9125c29791 100644 --- a/src/screens/reader/components/ReaderBottomSheet/ReaderTTSTab.tsx +++ b/src/screens/reader/components/ReaderBottomSheet/ReaderTTSTab.tsx @@ -16,6 +16,7 @@ import { useBoolean } from '@hooks'; import { Portal } from 'react-native-paper'; import VoicePickerModal from '@screens/settings/SettingsReaderScreen/Modals/VoicePickerModal'; import EnginePickerModal from '@screens/settings/SettingsReaderScreen/Modals/EnginePickerModal'; +import TtsTextCleanupModal from '@screens/settings/SettingsReaderScreen/Modals/TtsTextCleanupModal'; import TTSScrollBehaviorModal from '@screens/settings/SettingsReaderScreen/Modals/TTSScrollBehaviorModal'; import Switch from '@components/Switch/Switch'; import { useChapterContext } from '../../ChapterContext'; @@ -26,6 +27,7 @@ import { } from '@services/tts/novelTtsSettings'; import { NovelInfo } from '@database/types'; import { createRateLimitedLogger } from '@utils/rateLimitedLogger'; +import { DEFAULT_TTS_CLEANUP_SETTINGS } from '@utils/htmlParagraphExtractor'; const readerTTSTabLog = createRateLimitedLogger('ReaderTTSTab', { windowMs: 1500, @@ -62,6 +64,7 @@ const ReaderTTSTab: React.FC = React.memo( ttsAutoStopMode = 'off', ttsAutoStopAmount = 0, ttsShowGestureHints = true, + ttsTextCleanup = DEFAULT_TTS_CLEANUP_SETTINGS, setChapterGeneralSettings, } = useChapterGeneralSettings(); @@ -409,6 +412,11 @@ const ReaderTTSTab: React.FC = React.memo( setTrue: showTtsAutoStopAmountModal, setFalse: hideTtsAutoStopAmountModal, } = useBoolean(); + const { + value: ttsTextCleanupModalVisible, + setTrue: showTtsTextCleanupModal, + setFalse: hideTtsTextCleanupModal, + } = useBoolean(); useEffect(() => { TTSHighlight.getVoices().then(res => { @@ -838,6 +846,21 @@ const ReaderTTSTab: React.FC = React.memo( + {/* TTS Text Cleanup Settings */} + + Text Cleanup + r.enabled).length} active rules · ${ttsTextCleanup.phoneticPairs.filter(p => p.enabled).length} phonetic` + : 'Disabled' + } + onPress={showTtsTextCleanupModal} + theme={theme} + /> + + {/* Auto-Download Settings */} Auto-Download @@ -1016,6 +1039,14 @@ const ReaderTTSTab: React.FC = React.memo( ] } /> + + setChapterGeneralSettings({ ttsTextCleanup: nextSettings }) + } + /> ); diff --git a/src/screens/reader/components/WebViewReader.tsx b/src/screens/reader/components/WebViewReader.tsx index 70a2a1cfec..698f5d527b 100644 --- a/src/screens/reader/components/WebViewReader.tsx +++ b/src/screens/reader/components/WebViewReader.tsx @@ -65,7 +65,10 @@ import TTSChapterSelectionDialog from './TTSChapterSelectionDialog'; import TTSSyncDialog from './TTSSyncDialog'; import Toast from '@components/Toast'; import { useBoolean, useBackHandler } from '@hooks'; -import { extractParagraphs } from '@utils/htmlParagraphExtractor'; +import { + extractParagraphs, + applyTtsTextCleanup, +} from '@utils/htmlParagraphExtractor'; import { applyTtsUpdateToWebView, type TTSSettings } from './ttsHelpers'; import TTSExitDialog from './TTSExitDialog'; import { @@ -400,7 +403,10 @@ const WebViewReaderRefactored: React.FC = ({ onPress }) => { TTSHighlight.stop(); const idx = tts.currentParagraphIndex; - const paragraphs = extractParagraphs(html, chapter.name); + const paragraphs = applyTtsTextCleanup( + extractParagraphs(html, chapter.name), + chapterGeneralSettingsRef.current?.ttsTextCleanup, + ); if (paragraphs && paragraphs.length > idx) { tts.restartTtsFromParagraphIndex(idx); diff --git a/src/screens/reader/components/__tests__/WebViewReader.eventHandlers.test.tsx b/src/screens/reader/components/__tests__/WebViewReader.eventHandlers.test.tsx index de9513d70b..ba4065e960 100644 --- a/src/screens/reader/components/__tests__/WebViewReader.eventHandlers.test.tsx +++ b/src/screens/reader/components/__tests__/WebViewReader.eventHandlers.test.tsx @@ -133,6 +133,8 @@ jest.mock('@utils/htmlParagraphExtractor', () => ({ 'Paragraph 4', 'Paragraph 5', ]), + applyTtsTextCleanup: jest.fn((paragraphs: string[]) => paragraphs), + cleanTtsText: jest.fn((text: string) => text), })); jest.mock('../ttsHelpers', () => ({ diff --git a/src/screens/reader/components/__tests__/WebViewReader.integration.test.tsx b/src/screens/reader/components/__tests__/WebViewReader.integration.test.tsx index 3848efe184..d5e85d88aa 100644 --- a/src/screens/reader/components/__tests__/WebViewReader.integration.test.tsx +++ b/src/screens/reader/components/__tests__/WebViewReader.integration.test.tsx @@ -113,6 +113,8 @@ jest.mock('@hooks', () => ({ jest.mock('@utils/htmlParagraphExtractor', () => ({ extractParagraphs: jest.fn(() => ['P1', 'P2', 'P3', 'P4', 'P5']), + applyTtsTextCleanup: jest.fn((paragraphs: string[]) => paragraphs), + cleanTtsText: jest.fn((text: string) => text), })); jest.mock('../ttsHelpers', () => ({ diff --git a/src/screens/reader/hooks/__tests__/useTTSController.integration.test.ts b/src/screens/reader/hooks/__tests__/useTTSController.integration.test.ts index 34c6ad0d0b..82da66abf5 100644 --- a/src/screens/reader/hooks/__tests__/useTTSController.integration.test.ts +++ b/src/screens/reader/hooks/__tests__/useTTSController.integration.test.ts @@ -87,6 +87,8 @@ jest.mock('@utils/htmlParagraphExtractor', () => ({ 'Fourth paragraph', 'Fifth paragraph', ]), + applyTtsTextCleanup: jest.fn((paragraphs: string[]) => paragraphs), + cleanTtsText: jest.fn((text: string) => text), })); jest.mock('../../components/ttsHelpers', () => ({ validateAndClampParagraphIndex: jest.fn(index => Math.max(0, index)), diff --git a/src/screens/reader/hooks/__tests__/useTTSController.mediaNav.test.ts b/src/screens/reader/hooks/__tests__/useTTSController.mediaNav.test.ts index 652e6388e8..c5820ad167 100644 --- a/src/screens/reader/hooks/__tests__/useTTSController.mediaNav.test.ts +++ b/src/screens/reader/hooks/__tests__/useTTSController.mediaNav.test.ts @@ -113,6 +113,8 @@ jest.mock('@database/db', () => { jest.mock('@utils/htmlParagraphExtractor', () => ({ extractParagraphs: jest.fn(() => ['Para 1', 'Para 2', 'Para 3']), + applyTtsTextCleanup: jest.fn((paragraphs: string[]) => paragraphs), + cleanTtsText: jest.fn((text: string) => text), })); jest.mock('@utils/Storages', () => ({ diff --git a/src/screens/reader/hooks/__tests__/useTTSProgressSync.test.ts b/src/screens/reader/hooks/__tests__/useTTSProgressSync.test.ts index 07eba152c3..a3065ead57 100644 --- a/src/screens/reader/hooks/__tests__/useTTSProgressSync.test.ts +++ b/src/screens/reader/hooks/__tests__/useTTSProgressSync.test.ts @@ -71,6 +71,8 @@ jest.mock('@utils/htmlParagraphExtractor', () => ({ 'Para 4', 'Para 5', ]), + applyTtsTextCleanup: jest.fn((paragraphs: string[]) => paragraphs), + cleanTtsText: jest.fn((text: string) => text), })); jest.mock('../../components/ttsHelpers', () => ({ validateAndClampParagraphIndex: jest.fn(idx => Math.max(0, idx)), diff --git a/src/screens/reader/hooks/__tests__/useTTSUtilities.test.ts b/src/screens/reader/hooks/__tests__/useTTSUtilities.test.ts index 7937a62ce2..9cd99daedc 100644 --- a/src/screens/reader/hooks/__tests__/useTTSUtilities.test.ts +++ b/src/screens/reader/hooks/__tests__/useTTSUtilities.test.ts @@ -14,7 +14,10 @@ import { renderHook, act } from '@testing-library/react-hooks'; import { useTTSUtilities } from '../useTTSUtilities'; import TTSHighlight from '@services/TTSHighlight'; import { MMKVStorage } from '@utils/mmkv/mmkv'; -import { extractParagraphs } from '@utils/htmlParagraphExtractor'; +import { + extractParagraphs, + applyTtsTextCleanup, +} from '@utils/htmlParagraphExtractor'; import { validateAndClampParagraphIndex } from '../../components/ttsHelpers'; // Mock dependencies @@ -37,6 +40,8 @@ jest.mock('@utils/mmkv/mmkv', () => ({ jest.mock('@utils/htmlParagraphExtractor', () => ({ extractParagraphs: jest.fn(), + applyTtsTextCleanup: jest.fn((paragraphs: string[]) => paragraphs), + cleanTtsText: jest.fn((text: string) => text), })); jest.mock('../../components/ttsHelpers', () => ({ @@ -47,6 +52,7 @@ describe('useTTSUtilities (Phase 1 - Step 2)', () => { // Mock refs let mockWebViewRef: any; let mockReaderSettingsRef: any; + let mockChapterGeneralSettingsRef: any; let mockRefs: any; // Mock data @@ -75,6 +81,11 @@ describe('useTTSUtilities (Phase 1 - Step 2)', () => { }, }; + // Setup chapter general settings ref + mockChapterGeneralSettingsRef = { + current: {}, + }; + // Setup all refs mockRefs = { currentParagraphIndexRef: { current: 0 }, @@ -108,6 +119,7 @@ describe('useTTSUtilities (Phase 1 - Step 2)', () => { html: mockHtml, webViewRef: mockWebViewRef, readerSettingsRef: mockReaderSettingsRef, + chapterGeneralSettingsRef: mockChapterGeneralSettingsRef, refs: mockRefs, }), ); @@ -126,6 +138,7 @@ describe('useTTSUtilities (Phase 1 - Step 2)', () => { html: mockHtml, webViewRef: mockWebViewRef, readerSettingsRef: mockReaderSettingsRef, + chapterGeneralSettingsRef: mockChapterGeneralSettingsRef, refs: mockRefs, }), ); @@ -153,6 +166,7 @@ describe('useTTSUtilities (Phase 1 - Step 2)', () => { html: mockHtml, webViewRef: mockWebViewRef, readerSettingsRef: mockReaderSettingsRef, + chapterGeneralSettingsRef: mockChapterGeneralSettingsRef, refs: mockRefs, }), ); @@ -192,6 +206,7 @@ describe('useTTSUtilities (Phase 1 - Step 2)', () => { html: mockHtml, webViewRef: mockWebViewRef, readerSettingsRef: mockReaderSettingsRef, + chapterGeneralSettingsRef: mockChapterGeneralSettingsRef, refs: mockRefs, }), ); @@ -217,6 +232,7 @@ describe('useTTSUtilities (Phase 1 - Step 2)', () => { html: mockHtml, webViewRef: mockWebViewRef, readerSettingsRef: mockReaderSettingsRef, + chapterGeneralSettingsRef: mockChapterGeneralSettingsRef, refs: mockRefs, }), ); @@ -251,6 +267,7 @@ describe('useTTSUtilities (Phase 1 - Step 2)', () => { html: mockHtml, webViewRef: mockWebViewRef, readerSettingsRef: mockReaderSettingsRef, + chapterGeneralSettingsRef: mockChapterGeneralSettingsRef, refs: mockRefs, }), ); @@ -277,6 +294,7 @@ describe('useTTSUtilities (Phase 1 - Step 2)', () => { html: mockHtml, webViewRef: mockWebViewRef, readerSettingsRef: mockReaderSettingsRef, + chapterGeneralSettingsRef: mockChapterGeneralSettingsRef, refs: mockRefs, }), ); @@ -302,6 +320,7 @@ describe('useTTSUtilities (Phase 1 - Step 2)', () => { html: mockHtml, webViewRef: mockWebViewRef, readerSettingsRef: mockReaderSettingsRef, + chapterGeneralSettingsRef: mockChapterGeneralSettingsRef, refs: mockRefs, }), ); @@ -327,6 +346,7 @@ describe('useTTSUtilities (Phase 1 - Step 2)', () => { html: mockHtml, webViewRef: mockWebViewRef, readerSettingsRef: mockReaderSettingsRef, + chapterGeneralSettingsRef: mockChapterGeneralSettingsRef, refs: mockRefs, }), ); @@ -354,6 +374,7 @@ describe('useTTSUtilities (Phase 1 - Step 2)', () => { html: mockHtml, webViewRef: mockWebViewRef, readerSettingsRef: mockReaderSettingsRef, + chapterGeneralSettingsRef: mockChapterGeneralSettingsRef, refs: mockRefs, }), ); @@ -379,6 +400,7 @@ describe('useTTSUtilities (Phase 1 - Step 2)', () => { html: mockHtml, webViewRef: mockWebViewRef, readerSettingsRef: mockReaderSettingsRef, + chapterGeneralSettingsRef: mockChapterGeneralSettingsRef, refs: mockRefs, }), ); @@ -398,6 +420,7 @@ describe('useTTSUtilities (Phase 1 - Step 2)', () => { html: mockHtml, webViewRef: mockWebViewRef, readerSettingsRef: mockReaderSettingsRef, + chapterGeneralSettingsRef: mockChapterGeneralSettingsRef, refs: mockRefs, }), ); @@ -417,6 +440,7 @@ describe('useTTSUtilities (Phase 1 - Step 2)', () => { html: mockHtml, webViewRef: mockWebViewRef, readerSettingsRef: mockReaderSettingsRef, + chapterGeneralSettingsRef: mockChapterGeneralSettingsRef, refs: mockRefs, }), ); @@ -445,6 +469,7 @@ describe('useTTSUtilities (Phase 1 - Step 2)', () => { html: mockHtml, webViewRef: mockWebViewRef, readerSettingsRef: mockReaderSettingsRef, + chapterGeneralSettingsRef: mockChapterGeneralSettingsRef, refs: mockRefs, }), ); @@ -457,6 +482,10 @@ describe('useTTSUtilities (Phase 1 - Step 2)', () => { mockHtml, mockChapter.name, ); + expect(applyTtsTextCleanup).toHaveBeenCalledWith( + expect.any(Array), + mockChapterGeneralSettingsRef.current?.ttsTextCleanup, + ); }); it('should return early if no paragraphs extracted', async () => { @@ -469,6 +498,7 @@ describe('useTTSUtilities (Phase 1 - Step 2)', () => { html: mockHtml, webViewRef: mockWebViewRef, readerSettingsRef: mockReaderSettingsRef, + chapterGeneralSettingsRef: mockChapterGeneralSettingsRef, refs: mockRefs, }), ); @@ -491,6 +521,7 @@ describe('useTTSUtilities (Phase 1 - Step 2)', () => { html: mockHtml, webViewRef: mockWebViewRef, readerSettingsRef: mockReaderSettingsRef, + chapterGeneralSettingsRef: mockChapterGeneralSettingsRef, refs: mockRefs, }), ); @@ -518,6 +549,7 @@ describe('useTTSUtilities (Phase 1 - Step 2)', () => { html: mockHtml, webViewRef: mockWebViewRef, readerSettingsRef: mockReaderSettingsRef, + chapterGeneralSettingsRef: mockChapterGeneralSettingsRef, refs: mockRefs, }), ); @@ -542,6 +574,7 @@ describe('useTTSUtilities (Phase 1 - Step 2)', () => { html: mockHtml, webViewRef: mockWebViewRef, readerSettingsRef: mockReaderSettingsRef, + chapterGeneralSettingsRef: mockChapterGeneralSettingsRef, refs: mockRefs, }), ); @@ -566,6 +599,7 @@ describe('useTTSUtilities (Phase 1 - Step 2)', () => { html: mockHtml, webViewRef: mockWebViewRef, readerSettingsRef: mockReaderSettingsRef, + chapterGeneralSettingsRef: mockChapterGeneralSettingsRef, refs: mockRefs, }), ); @@ -592,6 +626,7 @@ describe('useTTSUtilities (Phase 1 - Step 2)', () => { html: mockHtml, webViewRef: mockWebViewRef, readerSettingsRef: mockReaderSettingsRef, + chapterGeneralSettingsRef: mockChapterGeneralSettingsRef, refs: mockRefs, }), ); @@ -625,6 +660,7 @@ describe('useTTSUtilities (Phase 1 - Step 2)', () => { html: mockHtml, webViewRef: mockWebViewRef, readerSettingsRef: mockReaderSettingsRef, + chapterGeneralSettingsRef: mockChapterGeneralSettingsRef, refs: mockRefs, }), ); @@ -648,6 +684,7 @@ describe('useTTSUtilities (Phase 1 - Step 2)', () => { html: mockHtml, webViewRef: mockWebViewRef, readerSettingsRef: mockReaderSettingsRef, + chapterGeneralSettingsRef: mockChapterGeneralSettingsRef, refs: mockRefs, }), ); @@ -676,6 +713,7 @@ describe('useTTSUtilities (Phase 1 - Step 2)', () => { html: mockHtml, webViewRef: mockWebViewRef, readerSettingsRef: mockReaderSettingsRef, + chapterGeneralSettingsRef: mockChapterGeneralSettingsRef, refs: mockRefs, }), ); @@ -707,6 +745,7 @@ describe('useTTSUtilities (Phase 1 - Step 2)', () => { html: mockHtml, webViewRef: mockWebViewRef, readerSettingsRef: mockReaderSettingsRef, + chapterGeneralSettingsRef: mockChapterGeneralSettingsRef, refs: mockRefs, }), ); diff --git a/src/screens/reader/hooks/useTTSController.ts b/src/screens/reader/hooks/useTTSController.ts index b9051b2a3f..7160f688e5 100644 --- a/src/screens/reader/hooks/useTTSController.ts +++ b/src/screens/reader/hooks/useTTSController.ts @@ -18,7 +18,11 @@ import TTSHighlight from '@services/TTSHighlight'; import TTSAudioManager from '@services/TTSAudioManager'; import { TTSState } from '@services/TTSState'; import { MMKVStorage, getMMKVObject } from '@utils/mmkv/mmkv'; -import { extractParagraphs } from '@utils/htmlParagraphExtractor'; +import { + extractParagraphs, + applyTtsTextCleanup, + cleanTtsText, +} from '@utils/htmlParagraphExtractor'; import { getChapter as getChapterFromDb, updateChapterProgress as updateChapterProgressDb, @@ -530,6 +534,7 @@ export function useTTSController( html, webViewRef, readerSettingsRef, + chapterGeneralSettingsRef, refs: { currentParagraphIndexRef, totalParagraphsRef, @@ -687,8 +692,11 @@ export function useTTSController( isWebViewSyncedRef.current = true; ttsCtrlLog.debug('webview-synced-background'); - // Extract paragraphs from HTML - const paragraphs = extractParagraphs(html, chapterName); + // Extract paragraphs from HTML (with TTS text cleanup applied) + const paragraphs = applyTtsTextCleanup( + extractParagraphs(html, chapterName), + chapterGeneralSettingsRef.current?.ttsTextCleanup, + ); ttsCtrlLog.debug('background-paragraphs', `count=${paragraphs.length}`); if (paragraphs.length === 0) { @@ -824,7 +832,10 @@ export function useTTSController( MMKVStorage.getNumber(`chapter_progress_${chapterId}`) ?? -1; if (savedMMKVIndex >= 0) { - const paragraphs = extractParagraphs(html, chapterName); + const paragraphs = applyTtsTextCleanup( + extractParagraphs(html, chapterName), + chapterGeneralSettingsRef.current?.ttsTextCleanup, + ); const totalParagraphs = paragraphs?.length || 0; // If saved index is beyond chapter bounds, it's likely an off-by-one error @@ -1073,10 +1084,16 @@ export function useTTSController( } // UNIFIED BATCH MODE: Always use speakBatch - const textToSpeak = event.data as string; + const textToSpeak = cleanTtsText( + event.data as string, + chapterGeneralSettingsRef.current?.ttsTextCleanup, + ); let paragraphs: string[] = []; try { - paragraphs = extractParagraphs(html, chapterName); + paragraphs = applyTtsTextCleanup( + extractParagraphs(html, chapterName), + chapterGeneralSettingsRef.current?.ttsTextCleanup, + ); } catch (e) { ttsCtrlLog.error('extract-paragraphs-failed', e); } @@ -1341,24 +1358,30 @@ export function useTTSController( } ttsCtrlLog.info('tts-queue-accept', `start=${incomingStart}`); + // Apply TTS text cleanup to the DOM-fed refill paragraphs (Path B) + // before they reach the native TTS engine. + const cleanedQueueTexts = applyTtsTextCleanup( + event.data as string[], + chapterGeneralSettingsRef.current?.ttsTextCleanup, + ); ttsQueueRef.current = { startIndex: event.startIndex, - texts: event.data as string[], + texts: cleanedQueueTexts, }; // Use batch TTS for background playback if ( chapterGeneralSettingsRef.current.ttsBackgroundPlayback && - event.data.length > 0 + cleanedQueueTexts.length > 0 ) { const startIndex = event.startIndex; - const utteranceIds = (event.data as string[]).map( + const utteranceIds = cleanedQueueTexts.map( (_, i) => `chapter_${chapterId}_utterance_${startIndex + i}`, ); ttsCtrlLog.debug( 'add-to-batch', - `Adding ${event.data.length} paragraphs to TTS queue from index ${startIndex}`, + `Adding ${cleanedQueueTexts.length} paragraphs to TTS queue from index ${startIndex}`, ); const addToBatchWithRetry = async ( @@ -1388,7 +1411,7 @@ export function useTTSController( return false; }; - addToBatchWithRetry(event.data as string[], utteranceIds) + addToBatchWithRetry(cleanedQueueTexts, utteranceIds) .then(success => { if (!success) { ttsCtrlLog.error( @@ -1477,7 +1500,10 @@ export function useTTSController( ); // Calculate progress info for the error dialog - const retryParagraphs = extractParagraphs(html, chapterName); + const retryParagraphs = applyTtsTextCleanup( + extractParagraphs(html, chapterName), + chapterGeneralSettingsRef.current?.ttsTextCleanup, + ); const retryTotalParagraphs = retryParagraphs?.length ?? 0; const paragraphIdx = savedWakeParagraphIdx ?? 0; const progressPercent = @@ -1594,7 +1620,10 @@ export function useTTSController( savedWakeParagraphIdx, ); - const paragraphs = extractParagraphs(html, chapterName); + const paragraphs = applyTtsTextCleanup( + extractParagraphs(html, chapterName), + chapterGeneralSettingsRef.current?.ttsTextCleanup, + ); if (paragraphs && paragraphs.length > savedWakeParagraphIdx) { // CRITICAL FIX (Bug #2): Inject scroll restoration BEFORE resuming playback // This ensures WebView scrolls to the correct paragraph when user returns from background @@ -1834,7 +1863,10 @@ export function useTTSController( useEffect(() => { if (html) { - const paragraphs = extractParagraphs(html, chapterName); + const paragraphs = applyTtsTextCleanup( + extractParagraphs(html, chapterName), + chapterGeneralSettingsRef.current?.ttsTextCleanup, + ); totalParagraphsRef.current = paragraphs?.length || 0; updateTtsMediaNotificationState(isTTSReadingRef.current); } @@ -3303,7 +3335,10 @@ export function useTTSController( if (idx >= 0) { // Attempt to resume using native batch playback try { - const paragraphs = extractParagraphs(html, chapterName); + const paragraphs = applyTtsTextCleanup( + extractParagraphs(html, chapterName), + chapterGeneralSettingsRef.current?.ttsTextCleanup, + ); if (paragraphs && paragraphs.length > idx) { const remaining = paragraphs.slice(idx); const ids = remaining.map( diff --git a/src/screens/reader/hooks/useTTSUtilities.ts b/src/screens/reader/hooks/useTTSUtilities.ts index 307207efe3..ab296e20d9 100644 --- a/src/screens/reader/hooks/useTTSUtilities.ts +++ b/src/screens/reader/hooks/useTTSUtilities.ts @@ -11,10 +11,16 @@ import { useCallback, RefObject } from 'react'; import WebView from 'react-native-webview'; import TTSHighlight from '@services/TTSHighlight'; import { MMKVStorage } from '@utils/mmkv/mmkv'; -import { extractParagraphs } from '@utils/htmlParagraphExtractor'; +import { + extractParagraphs, + applyTtsTextCleanup, +} from '@utils/htmlParagraphExtractor'; import { validateAndClampParagraphIndex } from '../components/ttsHelpers'; import { ChapterInfo, NovelInfo } from '@database/types'; -import { ChapterReaderSettings } from '@hooks/persisted/useSettings'; +import { + ChapterReaderSettings, + ChapterGeneralSettings, +} from '@hooks/persisted/useSettings'; import { TTSQueueState, TTSPersistenceState } from '../types/tts'; /** @@ -26,6 +32,7 @@ export interface TTSUtilitiesParams { html: string; webViewRef: RefObject; readerSettingsRef: RefObject; + chapterGeneralSettingsRef: RefObject; refs: { currentParagraphIndexRef: RefObject; totalParagraphsRef: RefObject; @@ -62,6 +69,7 @@ export function useTTSUtilities(params: TTSUtilitiesParams): TTSUtilities { html, webViewRef, readerSettingsRef, + chapterGeneralSettingsRef, refs: { currentParagraphIndexRef, totalParagraphsRef, @@ -140,7 +148,10 @@ export function useTTSUtilities(params: TTSUtilitiesParams): TTSUtilities { */ const restartTtsFromParagraphIndex = useCallback( async (targetIndex: number) => { - const paragraphs = extractParagraphs(html, chapter.name); + const paragraphs = applyTtsTextCleanup( + extractParagraphs(html, chapter.name), + chapterGeneralSettingsRef.current?.ttsTextCleanup, + ); if (!paragraphs || paragraphs.length === 0) return; const clamped = validateAndClampParagraphIndex( @@ -185,6 +196,7 @@ export function useTTSUtilities(params: TTSUtilitiesParams): TTSUtilities { chapter.id, html, readerSettingsRef, + chapterGeneralSettingsRef, ttsQueueRef, currentParagraphIndexRef, latestParagraphIndexRef, diff --git a/src/screens/settings/SettingsReaderScreen/Modals/TtsTextCleanupModal.tsx b/src/screens/settings/SettingsReaderScreen/Modals/TtsTextCleanupModal.tsx new file mode 100644 index 0000000000..4a682e7200 --- /dev/null +++ b/src/screens/settings/SettingsReaderScreen/Modals/TtsTextCleanupModal.tsx @@ -0,0 +1,551 @@ +import React, { useEffect, useMemo, useState } from 'react'; +import { StyleSheet, View, ScrollView, Dimensions } from 'react-native'; +import { Portal, TextInput } from 'react-native-paper'; +import Modal from '@components/Modal/Modal'; +import List from '@components/List/List'; +import Switch from '@components/Switch/Switch'; +import AppText from '@components/AppText'; +import Button from '@components/Button/Button'; +import { IconButtonV2 } from '@components/index'; +import { useTheme, useAppSettings } from '@hooks/persisted'; +import { scaleDimension } from '@theme/scaling'; +import { + TtsTextCleanupSettings, + TtsCleanupRule, + TtsPhoneticPair, + createTtsCleanupRule, + createTtsPhoneticPair, +} from '@utils/htmlParagraphExtractor'; + +interface TtsTextCleanupModalProps { + visible: boolean; + onDismiss: () => void; + settings: TtsTextCleanupSettings; + onSave: (settings: TtsTextCleanupSettings) => void; +} + +type EditorMode = 'list' | 'rule' | 'pair'; + +interface RuleFormState { + id?: string; + pattern: string; + replacement: string; + isRegex: boolean; + flags: string; +} + +interface PairFormState { + id?: string; + word: string; + pronunciation: string; +} + +const EMPTY_RULE_FORM: RuleFormState = { + pattern: '', + replacement: '', + isRegex: false, + flags: 'g', +}; + +const EMPTY_PAIR_FORM: PairFormState = { + word: '', + pronunciation: '', +}; + +const formatRuleSummary = (rule: TtsCleanupRule): string => { + const find = rule.isRegex + ? `/${rule.pattern}/${rule.flags}` + : `"${rule.pattern}"`; + const replace = rule.replacement === '' ? '(strip)' : `"${rule.replacement}"`; + return `${find} → ${replace}`; +}; + +const formatPairSummary = (pair: TtsPhoneticPair): string => + `"${pair.word}" → "${pair.pronunciation}"`; + +const TtsTextCleanupModal: React.FC = ({ + visible, + onDismiss, + settings, + onSave, +}) => { + const theme = useTheme(); + const { uiScale = 1.0 } = useAppSettings(); + + const [draft, setDraft] = useState(settings); + const [mode, setMode] = useState('list'); + const [ruleForm, setRuleForm] = useState(EMPTY_RULE_FORM); + const [pairForm, setPairForm] = useState(EMPTY_PAIR_FORM); + + // Re-sync draft whenever the modal opens or settings change externally. + useEffect(() => { + if (visible) { + setDraft(settings); + setMode('list'); + setRuleForm(EMPTY_RULE_FORM); + setPairForm(EMPTY_PAIR_FORM); + } + }, [visible, settings]); + + const styles = useMemo( + () => + StyleSheet.create({ + containerStyle: { + maxHeight: Math.round(Dimensions.get('window').height * 0.75), + }, + content: { + paddingBottom: scaleDimension(16, uiScale), + }, + section: { + marginTop: scaleDimension(16, uiScale), + }, + masterRow: { + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'space-between', + paddingVertical: scaleDimension(8, uiScale), + }, + toggleRow: { + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'space-between', + paddingVertical: scaleDimension(6, uiScale), + }, + toggleLabel: { + flex: 1, + paddingRight: scaleDimension(12, uiScale), + }, + hint: { + fontSize: scaleDimension(12, uiScale), + marginTop: 2, + }, + ruleRow: { + flexDirection: 'row', + alignItems: 'center', + paddingVertical: scaleDimension(4, uiScale), + gap: scaleDimension(4, uiScale), + }, + ruleText: { + flex: 1, + fontSize: scaleDimension(13, uiScale), + }, + form: { + marginTop: scaleDimension(12, uiScale), + }, + formField: { + marginBottom: scaleDimension(8, uiScale), + backgroundColor: theme.surface2, + }, + formActions: { + flexDirection: 'row', + justifyContent: 'flex-end', + gap: scaleDimension(8, uiScale), + marginTop: scaleDimension(8, uiScale), + }, + emptyText: { + paddingVertical: scaleDimension(8, uiScale), + }, + addButtonContainer: { + marginTop: scaleDimension(8, uiScale), + }, + }), + [uiScale, theme.surface2], + ); + + const inputProps = { + mode: 'flat' as const, + autoCorrect: false, + autoCapitalize: 'none' as const, + spellCheck: false, + activeUnderlineColor: theme.primary, + textColor: theme.onSurface, + placeholderTextColor: theme.onSurfaceVariant, + }; + + const updateRule = (id: string, patch: Partial) => { + setDraft(d => ({ + ...d, + rules: d.rules.map(r => (r.id === id ? { ...r, ...patch } : r)), + })); + }; + + const removeRule = (id: string) => { + setDraft(d => ({ ...d, rules: d.rules.filter(r => r.id !== id) })); + }; + + const updatePair = (id: string, patch: Partial) => { + setDraft(d => ({ + ...d, + phoneticPairs: d.phoneticPairs.map(p => + p.id === id ? { ...p, ...patch } : p, + ), + })); + }; + + const removePair = (id: string) => { + setDraft(d => ({ + ...d, + phoneticPairs: d.phoneticPairs.filter(p => p.id !== id), + })); + }; + + const saveRule = () => { + if (!ruleForm.pattern.trim()) { + return; + } + setDraft(d => { + if (ruleForm.id) { + return { + ...d, + rules: d.rules.map(r => + r.id === ruleForm.id + ? { + ...r, + pattern: ruleForm.pattern, + replacement: ruleForm.replacement, + isRegex: ruleForm.isRegex, + flags: ruleForm.flags, + } + : r, + ), + }; + } + return { + ...d, + rules: [ + ...d.rules, + createTtsCleanupRule( + ruleForm.pattern, + ruleForm.replacement, + ruleForm.isRegex, + ruleForm.flags, + ), + ], + }; + }); + setMode('list'); + setRuleForm(EMPTY_RULE_FORM); + }; + + const savePair = () => { + if (!pairForm.word.trim()) { + return; + } + setDraft(d => { + if (pairForm.id) { + return { + ...d, + phoneticPairs: d.phoneticPairs.map(p => + p.id === pairForm.id + ? { + ...p, + word: pairForm.word, + pronunciation: pairForm.pronunciation, + } + : p, + ), + }; + } + return { + ...d, + phoneticPairs: [ + ...d.phoneticPairs, + createTtsPhoneticPair(pairForm.word, pairForm.pronunciation), + ], + }; + }); + setMode('list'); + setPairForm(EMPTY_PAIR_FORM); + }; + + return ( + + + + + + + + Clean TTS text + + + Applied to every paragraph before it reaches the TTS engine + + + + setDraft(d => ({ ...d, enabled: !d.enabled })) + } + /> + + + + + + Unicode normalization + + + NFD normalize + strip combining marks + + + + setDraft(d => ({ + ...d, + normalizeUnicode: !d.normalizeUnicode, + })) + } + /> + + + {mode === 'rule' ? ( + + + setRuleForm(f => ({ ...f, pattern: text })) + } + style={styles.formField} + /> + + setRuleForm(f => ({ ...f, replacement: text })) + } + style={styles.formField} + /> + + + + Treat as regex + + + Invalid regexes are skipped automatically + + + + setRuleForm(f => ({ ...f, isRegex: !f.isRegex })) + } + /> + + {ruleForm.isRegex && ( + + setRuleForm(f => ({ ...f, flags: text })) + } + style={styles.formField} + /> + )} + +