Skip to content

feat(tts): add declarative TTS text cleanup — strip watermarks & fix pronunciations (#17) - #18

Merged
bizzkoot merged 9 commits into
masterfrom
dev
Aug 3, 2026
Merged

feat(tts): add declarative TTS text cleanup — strip watermarks & fix pronunciations (#17)#18
bizzkoot merged 9 commits into
masterfrom
dev

Conversation

@bizzkoot

@bizzkoot bizzkoot commented Aug 2, 2026

Copy link
Copy Markdown
Owner

1. Summary

Implements a declarative, length-preserving TTS text-cleanup pipeline so users can strip anti-scraper watermarks (e.g. Novelight) and fix LN name pronunciations before text reaches the native Android TTS engine — across all playback modes and paths, including background playback. Includes per-novel overrides, regex-safety hardening, a full rule/phonetic editor, one-tap curated presets, and JSON import/export for sharing/backing up rule sets.

The issue's proposed mechanism (window.tts.getTextNodes() + executeCustomUserReaderJS synchronous hook) was investigated and rejected — see §3. The chosen approach applies a settings-driven rule pipeline in the React Native layer instead.


2. Issue context

  • Issue: Execute Custom JS prior to TTS queue extraction in core.js #17 — "Execute Custom JS prior to TTS queue extraction in core.js" (open; reporter snthsh2)
  • Problem: Sites like Novelight inject watermark text (unicode lookalike letters, literal u2014 string corruption, "Do not rehost this novel" / "(Official version)" phrases) that bypass user customJS cleaning and get read aloud by TTS.
  • Second use case: phonetic pronunciation swaps (Xianxia → "Shee-an-shah", Qing → "Ching", Ainz → "Ownz") so system engines stop mispronouncing LN names.

2.1 Why the proposed hook was rejected (verified against codebase)

Issue claim Verdict
"Background service grabs elements via window.tts.getTextNodes()" getTextNodes doesn't exist in core.js; native speakBatch receives List<String> only
"Custom scripts haven't finished when TTS grabs text" ❌ Initial queue is RN-parsed from HTML (extractParagraphs), never reads the DOM
"Hook inside getTextNodes will fix watermarks" ❌ Initial queue ignores the WebView entirely in every mode; hook would change nothing
"App reads DOM text out loud (watermarks leak)" ✅ Partially true — foreground DOM refills (tts-queue) and fallback single-speak do use DOM textContent

2.2 Actual TTS audio data flow (the crux)

Flow Text source Covered by cleanup
Initial queue — foreground & background (default) RN extractParagraphs(sanitizedHtml)
Foreground refill (Path B) WebView DOM el.textContent via tts-queue
Fallback single-speak WebView DOM textToSpeak via speak
Wake resume / retry / drift / restart / scroll-sync RN extractParagraphs

Background playback is the default mode (whole chapter queued from RN in one speakBatch), so a WebView-only mechanism could never clean background audio. Cleanup must happen RN-side — which is what this PR does.


3. What this PR does

3.1 New declarative cleanup engine — src/utils/htmlParagraphExtractor.ts

Pure functions + settings types (no eval/Function, no hardcoded site regexes):

  • TtsTextCleanupSettings — master enabled switch, normalizeUnicode (NFD + strip combining marks), ordered rules[], phoneticPairs[]
  • TtsCleanupRule — literal find/replace or regex (pattern + flags + replacement; empty replacement strips)
  • TtsPhoneticPair — whole-word, case-sensitive pronunciation swaps (unicode-aware boundaries)
  • cleanTtsText(text, settings) — pipeline: unicode normalization → ordered rules → phonetic dictionary
  • applyTtsTextCleanup(paragraphs, settings)length-preserving map (never drops/merges entries → RN↔WebView paragraph index contract intact)
  • Invalid regexes are caught and skipped (no crash); disabled/empty settings are fast no-ops (same array reference returned)

3.2 Wiring — every audio-feeding point (coverage map)

File Location Change
src/screens/reader/hooks/useTTSController.ts 698, 837, 1094, 1505, 1625, 1868, 3340 applyTtsTextCleanup(extractParagraphs(...), ref) on all RN-parse paths
src/screens/reader/hooks/useTTSController.ts ~1365 (tts-queue) cleanup on WebView DOM refill before addToBatch
src/screens/reader/hooks/useTTSController.ts ~1087 (speak case) cleanTtsText on textToSpeak (covers both fallback speaks) + cleaned re-extract
src/screens/reader/hooks/useTTSUtilities.ts ~153 cleanup in restart/seek/drift/scroll-sync path
src/screens/reader/components/WebViewReader.tsx ~408 cleanup in live-settings-change restart path
src/hooks/persisted/useSettings.ts ChapterGeneralSettings new ttsTextCleanup field + DEFAULT_TTS_CLEANUP_SETTINGS

Settings flow via chapterGeneralSettingsRef (kept fresh by MMKV listener + useEffect). Legacy persisted settings without the new key degrade safely (optional chaining + destructure defaults + no-op when enabled: false).

3.3 Settings UI

  • Global: More → Settings → Reader → Accessibility Tab — "Clean TTS text" switch + "Cleanup rules & phonetic dictionary" entry
  • Quick access: Reader Bottom Sheet → TTS Tab — "Text Cleanup" entry (shows a per-novel badge when an override is active)
  • Per-novel: when "Use settings for this novel" is enabled, saving from the reader quick access writes a frozen per-novel cleanup override; otherwise global
  • TtsTextCleanupModal (new): master toggle, unicode normalization toggle, ordered Find & Replace rule editor (literal or regex + flags, reorderable, save-time regex safety validation with inline errors), phonetic dictionary editor (whole-word or substring match mode for CJK, reorderable); both lists support add/edit/delete and per-item enable
  • Presets (one-tap): 7 curated templates in the editor (Novelight spaced watermark, u2014 corruption, "(Official version)" tags, "Do not rehost" spam, math-bold lookalikes, LN name pronunciations, CJK substring pairs). Presets are UI data only — never executed by the pipeline; applying merges them into the user's editable rule list (deduped), keeping the core site-agnostic
  • JSON import/export: versioned envelope (lnreader-tts-cleanup v1) shared via the native share sheet; import validates (id regeneration, regex length-cap + compile check, matchMode/flags coercion, invalid-entry skip with summary) and replaces the draft (Cancel discards)

4. Design constraints honored

  • Length-preserving — cleanup never changes paragraph count → RN↔WebView index/utterance-ID sync contract intact (highlight, scroll sync, progress)
  • No arbitrary user JS in the RN/Hermes layer — declarative rules only (security)
  • No hardcoded site regexes in the repo — everything user-configurable
  • Covers background playback — RN-side pipeline, not WebView-only
  • Backward compatible — missing persisted key degrades to no-op; no migration needed

5. Audit results (3 independent reviewers)

The implementation was audited by three independent fresh-context reviewers (core utility / TTS wiring / settings+UI), plus type-check, ESLint, and the full test suite. No blockers.

5.1 Findings addressed in this PR

  • All native audio entry points verified cleaned (11 extractParagraphs call sites + tts-queue + both speak fallbacks) — verified end-to-end by tracing every TTSHighlight.speak/speakBatch/addToBatch call
  • Unicode property escapes (\p{L}, \p{N}) and String.normalize('NFD') verified supported by the bundled Hermes runtime (RN 0.82)
  • Migration safety verified: legacy MMKV objects without ttsTextCleanup cannot crash any consumer

5.2 Audit follow-ups — ALL RESOLVED in follow-up commits (2026-08-02)

Finding Sev Resolution Commit
Per-novel override gap MAJOR NovelTtsSettings extended with optional ttsTextCleanup; resolveEffectiveTtsCleanup() writes the effective value into chapterGeneralSettingsRef (re-synced after every ref assignment + on novel/settings change); per-novel save routing + field preservation in all 4 setNovelTtsSettings writers aeec7abb6
Unvalidated user regex (ReDoS) MAJOR TTS_CLEANUP_MAX_REGEX_LENGTH (200) + isPotentiallyCatastrophic() (3 narrow structural heuristics, empirically validated — zero false positives on issue-thread patterns) runtime skip; save-time validation with inline error in the modal 1a1ffb00d
$&/$n replacement interpolation MINOR regex rules now use a callback replacement (fully literal) 1a1ffb00d
CJK phonetic matching no-op MINOR optional per-pair matchMode: 'substring' (split/join) + editor toggle; legacy pairs default to whole-word b0b56f333
Controller tests never assert cleanup wiring MINOR tts-queue handler test asserts applyTtsTextCleanup args; new speak fallback test asserts cleanTtsText args b47f527c0
Sticky y flag silent no-op MINOR normalizeRegExpFlags() strips y (verified /y and /gy never scan past index 0) + dedupes/validates flags, always forces g 1a1ffb00d
Normalize-before-rules defeats precomposed patterns MINOR literal rule patterns are NFD-normalized when unicode normalization is on 1a1ffb00d
Rule reorder UI missing MINOR move up/down arrows (disabled at boundaries) for rules and phonetic pairs b0b56f333
replaceWholeWord unguarded / per-paragraph recompile NIT try/catch around RegExp construction + bounded word-keyed cache 1a1ffb00d
String type-guard in cleanTtsText NIT typeof text !== 'string' early return 1a1ffb00d
i18n (hardcoded English) NIT Deferred — consistent with the entire existing TTS section in both files; a dedicated i18n-ify-TTS-settings PR is the right scope (see PR_17 §5.2 follow-up note)

Tests after follow-ups: 1256 passing (baseline 1196 + 60 new: 32 cleanup engine + 6 resolver + 1 controller wiring + 21 presets/import-export).


6. Tests

pnpm run type-check   # clean (tsc --noEmit, exit 0)
pnpm run test         # 1256 passed, 0 failed (71 suites) — baseline 1196 + 60 new
  • New: src/utils/__tests__/ttsTextCleanup.test.ts (32 tests) — literal/regex rules, invalid-regex skip, unicode normalization + lookalike mapping, phonetic whole-word swaps (incl. no-match-inside-words), pipeline ordering, length-preservation, null/undefined/disabled passthrough, regex-safety hardening
  • New: src/screens/settings/SettingsReaderScreen/Modals/__tests__/ttsCleanupPresets.test.ts (21 tests) — preset shape + regex safety, apply/merge/dedupe/immutability, serialize round-trip, import validation matrix (bad JSON/types, invalid-regex skip counts, id regeneration, flag fallbacks, empty import)
  • Updated mocks: 5 controller/component test files (applyTtsTextCleanup, cleanTtsText, chapterGeneralSettingsRef)
  • All TTS suites pass: useTTSUtilities, useTTSController.{integration,mediaNav}, useTTSProgressSync, WebViewReader.{eventHandlers,integration}, ttsCleanupPresets

7. Manual test checklist

  1. Open a chapter with a watermark site (or add a rule for u2014) → enable Clean TTS text in Reader → TTS Tab → add rule u2014 → replace with → play TTS (foreground) → confirm corrupted string is not spoken
  2. Repeat with background playback enabled (default) → confirm cleanup still applied
  3. Add phonetic pair XianxiaShee-an-shah → confirm pronunciation change
  4. Toggle Unicode normalization on a chapter with accented lookalikes
  5. Add an intentionally invalid regex (([unclosed) → confirm it is skipped, no crash, playback unaffected
  6. Adjust highlight offset / scroll while TTS playing → confirm highlight/scroll sync still aligned (index contract)
  7. Restart app → settings persist; rules still applied
  8. Presets: editor → Presets (one-tap) → tap Add on "Fix u2014 text corruption" → rule appears in Find & Replace (deduped if already present) → Save → confirm applied
  9. Export: tap Export JSON → share sheet → save/copy the JSON → confirm it is a versioned lnreader-tts-cleanup envelope
  10. Import (valid): delete all rules → tap Import JSON → paste the export → Import → rules restored; summary shown
  11. Import (invalid): paste garbage → error "Invalid JSON"; paste a JSON containing an invalid regex (([unclosed) → imported with "N invalid rules skipped" summary; no crash; Cancel discards everything

8. Files changed

Modified

  • src/utils/htmlParagraphExtractor.ts (+295) — cleanup engine, safety hardening, matchMode
  • src/hooks/persisted/useSettings.ts (+14)
  • src/services/tts/novelTtsSettings.ts (+40) — optional ttsTextCleanup + resolveEffectiveTtsCleanup
  • src/screens/reader/hooks/useTTSController.ts (+65/-15)
  • src/screens/reader/hooks/useTTSUtilities.ts (+18)
  • src/screens/reader/components/WebViewReader.tsx (+35) — effective-cleanup ref resolution
  • src/screens/settings/SettingsReaderScreen/tabs/AccessibilityTab.tsx (+38)
  • src/screens/reader/components/ReaderBottomSheet/ReaderTTSTab.tsx (+60) — per-novel cleanup UI + reorder/validation
  • src/screens/settings/SettingsReaderScreen/Modals/TtsTextCleanupModal.tsx (+141) — presets UI, import/export UI, Share integration
  • src/services/__tests__/NovelTtsSettings.test.ts (+70)
  • 6 test files (mock additions + wiring assertions)

New

  • src/screens/settings/SettingsReaderScreen/Modals/TtsTextCleanupModal.tsx
  • src/screens/settings/SettingsReaderScreen/Modals/ttsCleanupPresets.ts — preset data + applyPresetToSettings, serializeCleanupSettings, parseCleanupSettingsImport
  • src/screens/settings/SettingsReaderScreen/Modals/__tests__/ttsCleanupPresets.test.ts (21 tests)
  • src/utils/__tests__/ttsTextCleanup.test.ts (32 tests)

Commits (local, not pushed — 9 total)

  • 2d35beff0 feat(tts): add declarative text cleanup pipeline for TTS audio
  • 1a1ffb00d fix(tts): harden text cleanup regex safety and replacement semantics
  • aeec7abb6 feat(tts): support per-novel text cleanup overrides
  • b0b56f333 feat(tts): add substring match mode and rule reordering to cleanup editor
  • b47f527c0 test(tts): assert cleanup wiring in controller integration tests
  • e4a78d623 chore(tts): include stable sync callback in MMKV listener deps
  • a638ebe7e fix(tts): address text cleanup audit findings (modal robustness, docs, PRD)
  • 2d90673ed fix(tts): resolve remaining exhaustive-deps warnings
  • 20b55b719 feat(tts): add cleanup presets + JSON import/export to cleanup editor

9. Out of scope (explicitly rejected)

  • The issue's getTextNodes + executeCustomUserReaderJS synchronous hook (mechanism doesn't exist; wouldn't affect the initial queue; cannot run in background mode) — see §2.1
  • Hardcoding Novelight/any-site watermark regexes in the repo
  • Arbitrary user JS evaluation in RN (eval/Function)
  • Dropping/merging paragraphs from cleanup (breaks index contract)

10. User Guide — Visual Menu Map (ASCII + Mermaid)

The "TTS Text Cleanup" UI is ONE editor modal reached from TWO places. Whatever
you configure is applied to every paragraph BEFORE it reaches the native TTS
engine — in all playback modes (foreground & background) and on every path
(initial queue, WebView refills, fallback single-speak).

Format note: navigation flows and the processing pipeline are drawn as
Mermaid diagrams (rendered automatically by GitHub/GitLab); on-screen menu
layouts and editor forms are kept as ASCII mockups (Mermaid cannot express
UI layout).

10.1 How to access the menu

Both entry points converge on the same editor — navigation flow:

flowchart LR
    subgraph A["PATH A · GLOBAL SETTINGS (all novels)"]
        direction LR
        A1[More ...] --> A2[Settings] --> A3[Reader]
        A3 --> A4[Accessibility tab]
        A4 --> A5[TTS Text Cleanup section]
    end
    subgraph B["PATH B · QUICK ACCESS (per-chapter)"]
        direction LR
        B1[Open a chapter] --> B2[TTS bar / bottom sheet]
        B2 --> B3[TTS tab]
        B3 --> B4[Text Cleanup section]
    end
    A5 --> M[TtsTextCleanupModal]
    B4 --> M
Loading

Where the section sits on the Accessibility tab (global settings):

   +---------------------------------------------------------------+
   |  ... TTS voice & speed / engine settings ...                  |
   |                                                               |
   |  +---------------------------------------------------------+  |
   |  | TTS TEXT CLEANUP                                        |  |
   |  | [Switch] Clean TTS text                                 |  |
   |  |          Strip watermarks & fix pronunciation           |  |
   |  |          before TTS reads                               |  |
   |  | [Tap]    Cleanup rules & phonetic dictionary            |  |
   |  |          "2 active rules · 1 phonetic"                  |  |
   |  +---------------------------------------------------------+  |
   |                                                               |
   |  ... TTS scroll behavior / auto-stop ...                      |
   +---------------------------------------------------------------+

Where the section sits on the TTS tab (quick access, per-chapter):

   +---------------------------------------------------------------+
   |  ... voices, speed, pitch ...                                 |
   |                                                               |
   |  +---------------------------------------------------------+  |
   |  | TEXT CLEANUP                                            |  |
   |  | [Tap] Cleanup rules & phonetic dictionary               |  |
   |  |       "2 active rules · 1 phonetic · per-novel"         |  |
   |  +---------------------------------------------------------+  |
   |    ("per-novel" tag appears only when THIS novel has its     |
   |     own saved override)                                      |
   |                                                               |
   |  ... auto-download / sleep timer ...                          |
   +---------------------------------------------------------------+

Both paths open the same editor:

   TTS TEXT CLEANUP EDITOR  (TtsTextCleanupModal)
   ---------------------------------------------------------------

   +---------------------------------------------------------------+
   |  TTS Text Cleanup                         [Cancel]  [Save]   |
   |                                                               |
   |  [Switch] Clean TTS text                                     |
   |           Applied to every paragraph before it reaches the   |
   |           TTS engine                                         |
   |  [Switch] Unicode normalization                              |
   |           NFD normalize + strip combining marks              |
   |  [Export JSON]  [Import JSON]                                |
   |                                                               |
   |  PRESETS (ONE-TAP)                                            |
   |   Novelight watermark (spaced letters)          [Add]        |
   |   Fix "u2014" text corruption                    [Add]        |
   |   LN name pronunciations                        [Add]        |
   |   CJK name pronunciation (substring)            [Add]        |
   |   (7 curated templates - copies into your rules)              |
   |                                                               |
   |  FIND & REPLACE RULES          (applied top-to-bottom)        |
   |   [on]  "u2014" -> (strip)                  [^][v][E][X]     |
   |   [on]  /N(?:\W)*o(?:\W)*v...(g)/ -> ""      [^][v][E][X]     |
   |   [off] "Novelight" -> " "                  [^][v][E][X]     |
   |   [+ Add rule]                                                |
   |                                                               |
   |  PHONETIC DICTIONARY    (applied after rules, top-to-bottom)  |
   |   [on]  "Xianxia" -> "Shee-an-shah"          [^][v][E][X]     |
   |   [on]  "Qing" -> "Ching" (substring)        [^][v][E][X]     |
   |   [+ Add phonetic pair]                                       |
   |                                                               |
   |  row buttons:  [^] move up   [v] move down   [E] edit        |
   |                [X] delete    [on/off] enable/disable          |
   +---------------------------------------------------------------+

10.2 The rule editor (tap [+ Add rule] or the pencil [E])

   +---------------------------------------------------------------+
   |  [Find]  u2014                                                |
   |  [Replace with (empty = strip)]                                |
   |  [Switch] Treat as regex                                      |
   |  (when regex is ON)                                           |
   |     [Regex flags]  g   (default; "g" is always forced)        |
   |     Note: regex patterns match text after Unicode             |
   |     normalization - precomposed chars (e.g. e with accent)    |
   |     will not match normalized text.                           |
   |  [Cancel]                                  [Save]             |
   +---------------------------------------------------------------+

10.3 The phonetic pair editor (tap [+ Add phonetic pair] or [E])

   +---------------------------------------------------------------+
   |  [Word]  Xianxia                                              |
   |  [Say instead]  Shee-an-shah                                  |
   |  [Switch] Replace every occurrence (substring)                |
   |           Needed for unspaced CJK text; off = whole-word     |
   |           only (default)                                     |
   |  [Cancel]                                  [Save]             |
   +---------------------------------------------------------------+

10.4 What happens to your text (pipeline order)

flowchart TD
    P["Every paragraph (ANY playback path)"] --> N["1. Unicode normalization<br/>(optional: NFD + strip combining marks)"]
    N --> R["2. Find & Replace rules<br/>(ordered top-to-bottom, literal or regex, empty replacement = strip)"]
    R --> D["3. Phonetic dictionary<br/>(whole-word, or substring for CJK)"]
    D --> T["Native TTS engine"]
Loading

Length-preserving by design — the pipeline never changes the paragraph
count, so highlight, scroll and progress stay in sync with the RN <-> WebView
index contract.

10.5 Where a save goes (global vs per-novel)

flowchart TD
    S["Save cleanup settings"] --> E{"Which entry point?"}
    E -->|"PATH A - Accessibility tab"| G["GLOBAL settings<br/>(applied to every novel)"]
    E -->|"PATH B - Reader TTS tab"| M{"Per-novel mode ON?"}
    M -->|"OFF"| G
    M -->|"ON"| PN["PER-NOVEL override<br/>(this novel only - others keep global)"]
    PN --> TAG["List row shows the 'per-novel' tag"]
Loading

10.6 Limitations (read before building rules)

   1. REGEX LENGTH CAP ....... patterns > 200 chars are skipped (no error)
   2. DANGEROUS PATTERNS ..... nested quantifiers like (a+)+, (?:a*)*,
                              (a|a)+ are rejected at save time and also
                              skipped defensively at runtime (ReDoS guard)
   3. INVALID REGEX .......... skipped silently at runtime (no crash);
                              shows an inline error at save time
   4. UNICODE NORMALIZATION .. regex patterns match text AFTER it is
                              normalized (verbatim); a precomposed char
                              in a regex will not match. Literal rules
                              are normalized for you automatically
   5. CJK PHONETICS .......... whole-word mode never fires between
                              adjacent CJK characters - use "substring"
   6. LENGTH-PRESERVING ...... cleanup can NEVER drop/merge paragraphs;
                              the highlight/scroll/progress index
                              contract depends on it
   7. i18n ................... English-only UI for now (consistent with
                              the rest of the TTS settings; deferred)

10.7 How to use it properly (quick start)

   1. Open the editor from either path; flip "Clean TTS text" ON
   2. Watermarks / corrupted text:
        - literal rule:  Find "u2014" -> Replace " "   (or leave empty
          to strip)
        - regex rule:    use for spacing-tolerant watermarks such as
          the Novelight fragment; keep patterns short and simple
   3. Lookalike characters (mathematical-bold letters, etc.):
        - enable Unicode normalization
        - optionally add literal rules mapping lookalikes, e.g.
          Find the math-bold "N" char -> Replace "N"
   4. Names / honorifics:
        - add phonetic pairs, e.g. "Xianxia" -> "Shee-an-shah"
        - enable "substring" for unspaced CJK names (e.g.  Qin/Ching)
   5. ORDER MATTERS: rules run before phonetics, top-to-bottom.
      Reorder with [^]/[v] so earlier rules feed later ones
   6. Play a chapter in foreground AND background to confirm.
      No app restart needed; settings persist (MMKV)

10.8 Quick-reference examples

   Goal                       Find / Word          Replace / Say    Type
   -------------------------  -------------------  ---------------  ----------
   strip corrupted string     u2014                (empty = strip)  literal
   strip watermark phrase     "(Official version)" (empty)         regex
   strip spaced watermark     N(?:\W)*o(?:\W)*v...  (empty)        regex
   fix name pronunciation     Xianxia              Shee-an-shah     phonetic
   fix CJK pronunciation       Qin (substring)     Ching            phonetic
   normalize accents          (Unicode normalization toggle ON)     n/a

10.9 Presets + JSON import/export (commit 20b55b719)

  PRESETS (ONE-TAP)  - inside the editor, above Find & Replace Rules
  ------------------------------------------------------------------

   +---------------------------------------------------------------+
   |  PRESETS (ONE-TAP)                                            |
   |   Novelight watermark (spaced letters)          [Add]        |
   |   Fix "u2014" text corruption                    [Add]        |
   |   Strip "(Official version)" tags                [Add]        |
   |   Strip "Do not rehost this novel" spam         [Add]        |
   |   Fix math-bold lookalike letters               [Add]        |
   |   LN name pronunciations                        [Add]        |
   |   CJK name pronunciation (substring)            [Add]        |
   +---------------------------------------------------------------+
   Tap [Add] -> the preset's rules/phonetic pairs are COPIED into
   your own list (deduped - no duplicates). Fully editable after;
   nothing is saved until [Save]. Presets are UI DATA ONLY - the
   core pipeline stays site-agnostic (nothing is hardcoded there).


  IMPORT / EXPORT  - row under the Unicode normalization toggle
  ------------------------------------------------------------------

   +---------------------------------------------------------------+
   |  [Export JSON]  [Import JSON]                                 |
   |                                                               |
   |  EXPORT: opens the native share sheet with a versioned        |
   |  envelope:  { "format": "lnreader-tts-cleanup",              |
   |               "version": 1, "settings": { ... } }            |
   |                                                               |
   |  IMPORT: paste panel ->                                     |
   |   [JSON] (multiline paste)                                   |
   |   [Import]                                                   |
   |   - REPLACES the current rules/pairs (draft only - Cancel    |
   |     discards, nothing lost)                                  |
   |   - ids regenerated (never trusts imported ids)              |
   |   - regexes validated: length cap (200) + compile check;     |
   |     invalid/empty entries skipped with a summary:            |
   |     "Imported 3 rules, 2 phonetic pairs (1 invalid rule      |
   |     skipped)"                                                |
   |   - matchMode/flags coerced; missing master toggles fall     |
   |     back to the current values                               |
   +---------------------------------------------------------------+

Why presets are not "hardcoded site regexes": the presets are shipped
as UI template data and only enter the pipeline after a user taps Add,
landing in their own editable MMKV settings. The cleanup engine itself
remains site-agnostic — satisfying the issue's explicit request to keep the
repository lightweight (see §9).

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).
Closes the ISSUE #17 audit findings on the cleanup engine:

- ReDoS guard: reject regex patterns longer than
  TTS_CLEANUP_MAX_REGEX_LENGTH (200) or matching narrow
  catastrophic-backtracking shapes ((a+)+, (?:a*)*, (?:a+){2,},
  (a|a)+). Heuristics were validated empirically against the issue
  thread's own patterns and 20 realistic safe patterns (zero false
  positives). Save-time validation in the cleanup modal surfaces the
  rejection; runtime skip keeps TTS from ever freezing.
- Literal replacement semantics: regex rules now use a callback so
  $&, $', \$` , $$ and $n in the replacement stay literal.
- Flag normalization: dedupe + strip invalid flags + always force g;
  drop the sticky y flag which silently no-ops on mid-string matches
  (verified /y and /gy do not scan past index 0).
- NFD pattern matching: literal rule patterns are NFD-normalized when
  unicode normalization is on, so precomposed chars like é still match
  normalized text (regex patterns documented as matched verbatim).
- Defensive replaceWholeWord: try/catch around RegExp construction
  with a bounded word-keyed cache (avoids recompiling per paragraph
  across 2000-paragraph queue builds).
- String type-guard in cleanTtsText for non-string runtime inputs.

Tests: +10 (heuristic detection/safe-pattern regression, length cap,
catastrophic skip ordering, literal $ semantics, sticky-as-global,
precomposed matching, flag normalization). 30/30 cleanup tests pass.
Closes the ISSUE #17 audit MAJOR finding: ttsTextCleanup was
global-only while the PR design promised per-novel support 'like other
TTS settings' (voice/rate/pitch via NovelTtsSettings).

- novelTtsSettings.ts: optional ttsTextCleanup field on NovelTtsSettings
  (backward compatible — absent for legacy objects) + exported
  resolveEffectiveTtsCleanup(global, novelId) returning the per-novel
  override when per-novel mode is enabled AND a cleanup override was
  saved, else the global settings. Any MMKV read failure degrades to
  global.
- WebViewReader.tsx: chapterGeneralSettingsRef now carries the
  EFFECTIVE cleanup. A syncEffectiveTtsCleanup callback re-resolves
  after every wholesale ref assignment (ref-sync effect, MMKV
  CHAPTER_GENERAL_SETTINGS listener) plus a new effect reacting to
  novel/novelTtsSettings/global changes. novelIdRef mirrors novel.id for
  the mount-once listener. All 10 existing cleanup call sites
  (useTTSController/useTTSUtilities/WebViewReader) automatically read
  the effective value with zero controller changes.
- ReaderTTSTab.tsx: per-novel cleanup state + effectiveCleanup memo;
  Text Cleanup entry shows per-novel badge; modal save routes to
  setNovelTtsSettings when per-novel mode is on (writing a frozen
  per-novel copy) else to global. All four setNovelTtsSettings writers
  now preserve an existing ttsTextCleanup so it is never dropped.
- Tests: +6 resolver unit tests (undefined novelId, absent settings,
  disabled mode, saved override, enabled-without-override, MMKV throw);
  WebViewReader test mocks upgraded with a resolver mirroring the real
  one against the mocked getNovelTtsSettings.

Per-novel semantics: enabling per-novel TTS (voice/rate/pitch) also
activates per-novel cleanup when one has been saved; otherwise global
cleanup applies. AccessibilityTab (global editor) is unaffected.
…itor

Closes two ISSUE #17 audit MINOR findings:

- CJK phonetic matching: whole-word boundaries never fire between
  adjacent CJK characters (both sides are \p{L}), making the
  pronunciation dictionary a silent no-op for unspaced CJK prose.
  TtsPhoneticPair gains an optional matchMode ('whole-word' default |
  'substring'); substring mode replaces every occurrence via
  split/join. The pair editor exposes a toggle. Legacy persisted pairs
  without the field default to whole-word (backward compatible).
- Rule reorder UI: rules are applied in array order, so the editor now
  supports move up/down (arrow icons, disabled at boundaries) for both
  find/replace rules and phonetic pairs, per the PR design's ordered
  rule list.

Tests: +2 (substring CJK replacement; absent-matchMode defaults to
whole-word preserving legacy behavior). 32/32 cleanup tests pass.
Closes the ISSUE #17 audit MINOR finding: applyTtsTextCleanup and
cleanTtsText were identity-mocked in the controller tests but never
asserted, so removing the wiring from the 'speak' or 'tts-queue'
handlers would not have failed any test.

- useTTSController.integration.test.ts: the tts-queue handler test now
  asserts applyTtsTextCleanup is called with the queue texts + effective
  settings before addToBatch; a new test asserts cleanTtsText is called
  with the DOM text + settings on the 'speak' fallback path.
- mediaNav/progressSync suites do not dispatch speak/tts-queue messages
  (mediaNav mocks useTTSUtilities; progressSync is a self-contained
  simulation), and the WebViewReader restart branch is guarded by a live
  TTS reading state the harness cannot reach — both documented as
  covered elsewhere (useTTSUtilities.test.ts already asserts the
  restart/seek path).
Suppresses a react-hooks/exhaustive-deps warning introduced by the
per-novel cleanup wiring: syncEffectiveTtsCleanup is a stable useCallback
([] deps), so referencing it in the mount-once MMKV listener is safe and
the dependency array can list it explicitly.
- fix(tts): include chapterGeneralSettingsRef in controller effect deps
  (resolves 3 exhaustive-deps warnings introduced by cleanup wiring)
- fix(tts): guard cleanup modal against partial stored settings objects
  (normalize rules/phoneticPairs at source + defensive ?? [] in entry points)
- docs(tts): add unicode normalization hint to regex rule editor
- docs(tts): add specs/tts-text-cleanup/PRD.md and update AGENTS.md
  (Current Task, Recent Fixes, TTS File Map)
- useTTSController.ts: include chapterName in 4 effect/callback dep arrays
  and restoreSavedEngine (stable useCallback, [] deps) in the dialog
  handlers callback — behavior-neutral, no new effect re-runs
- useTTSUtilities.ts: include chapter.name in restart-from-index callback
- WebViewReader.tsx warnings intentionally left untouched (documented
  design: adding offset/haptic deps caused WebView reload regressions)
- Preset Library: 7 curated one-tap templates (Novelight spaced watermark,
  u2014 corruption, '(Official version)' tags, 'Do not rehost' spam,
  math-bold lookalikes, LN name pronunciations, CJK substring pairs)
  shipped as DATA only - applied by copying into the user's editable
  rule list (MMKV); the core pipeline stays site-agnostic (PR_17 §10)
- Import/Export: versioned JSON envelope ('lnreader-tts-cleanup' v1) via
  native share sheet; import sanitizes + regenerates ids, validates
  regexes (length cap, compile check), coerces matchMode/flags, skips
  invalid entries with a summary; replace-in-draft (Cancel discards)
- Pure logic in ttsCleanupPresets.ts (applyPresetToSettings,
  serializeCleanupSettings, parseCleanupSettingsImport) + 21 unit tests
- docs(README): add TTS Text Cleanup feature table row, showcase
  subsection with pipeline diagram, What's New entries, Getting Started
  guide, and TOC links
@bizzkoot
bizzkoot merged commit a7c030a into master Aug 3, 2026
1 check passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant