設(shè)置:從設(shè)置面板到 TTSController 雙點門控的實現(xiàn)全解)
Readest TTS 高亮粒度逐詞 / 逐句設(shè)置從設(shè)置面板到 TTSController 雙點門控的實現(xiàn)全解【免費下載鏈接】readestReadest is a modern, feature-rich ebook reader designed for avid readers offering seamless cross-platform access, powerful tools, and an intuitive interface to elevate your reading experience.項目地址: https://gitcode.com/gh_mirrors/re/readest導(dǎo)讀本文以 Readest 倉庫中的apps/readest-app/.claude/memory/tts-highlight-granularity-setting.md記憶文檔為骨架完整梳理 TTS Highlighting 面板中GranularityWord / Sentence設(shè)置從數(shù)據(jù)模型、UI 持久化到TTSController運行時門控的完整鏈路。你將掌握該設(shè)置的數(shù)據(jù)類型與默認(rèn)值定義在哪里、如何在設(shè)置面板讀寫并持久化、不同 TTS 引擎Edge / Web / Native / Media Overlay的能力差異如何影響高亮回退語義以及TTSController中dispatchSpeakMark抑制與prepareSpeakWords提前返回這兩個關(guān)鍵門控點為何必須分開實現(xiàn)。一、功能總覽一個設(shè)置兩檔高亮粒度Readest 的朗讀Read Aloud功能在閱讀過程中會把正在朗讀的文本在頁面上高亮出來。用戶可以在設(shè)置 → TTS → TTS Highlighting分組中通過第一行的Granularity下拉框選擇高亮跟隨的粒度Word默認(rèn)逐詞高亮語音讀到哪個詞頁面就高亮哪個詞Sentence整句高亮語音進(jìn)入某個句子后該句整體保持高亮直到下一句開始。該下拉框位于 Style 選擇之前屬于TTSHighlightStyleEditor這個高亮樣式編輯器的一部分同一分組還包含 StyleHighlighter / Underline / Strikethrough / Squiggly / Outline以及 Color 與 Quick Colors 調(diào)色板。一個關(guān)鍵前提是逐詞高亮并非所有語音引擎都能提供。Readest 假設(shè)每個引擎都支持句子級高亮因此當(dāng)用戶選擇了word而當(dāng)前引擎不提供詞邊界word boundary信息時系統(tǒng)會自動回退為句子級高亮不會出現(xiàn)選不到、報錯的情況。二、數(shù)據(jù)模型與默認(rèn)值該設(shè)置對應(yīng)的字段是ttsHighlightGranularity: TTSHighlightGranularity掛在視圖設(shè)置中的 TTS 配置塊上。類型定義在 services/tts/types.tsexport type TTSGranularity sentence | word; export type TTSHighlightGranularity word | sentence;注意區(qū)分兩個看似相近的類型TTSGranularity用于 foliate-js 的TTS文本迭代器決定段落/句子的切分方式在#initTTSForSection中會根據(jù)書語言是否為 CJK 以及客戶端getGranularities()的支持情況決定TTSHighlightGranularity是面向用戶的顯示粒度決定高亮是逐詞繪制還是整句繪制即本文主角。默認(rèn)值為word定義在 services/constants.ts 的DEFAULT_TTS_CONFIG中與ttsHighlightOptions默認(rèn){ style: highlight, color: #808080 }、ttsMediaMetadata、ttsPlayerStyle等同組ttsHighlightOptions: { style: highlight, color: #808080 }, ttsHighlightGranularity: word, ttsMediaMetadata: sentence, ttsPlayerStyle: full, ttsSkipInlineAnnotations: false,用戶可選項嚴(yán)格限定為word/sentence兩個值TTSPanel讀取時以viewSettings.ttsHighlightGranularity ?? word兜底useTTSControl創(chuàng)建控制器時也以viewSettings.ttsHighlightGranularity ?? word作為初始值保證舊數(shù)據(jù)缺省時按逐詞模式工作。三、設(shè)置面板 UI 與持久化鏈路3.1 下拉框組件Granularity 下拉框由 components/settings/theme/TTSHighlightStyleEditor.tsx 渲染是整個 TTS HighlightingBoxedList的第一行Style 之前。組件通過受控 props 與父級交互BoxedList title{_(TTS Highlighting)} SettingsRow label{_(Granularity)} SettingsSelect value{granularity} onChange{(e) onGranularityChange(e.target.value as TTSHighlightGranularity)} ariaLabel{_(Granularity)} options{[ { value: word, label: _(Word) }, { value: sentence, label: _(Sentence) }, ]} / /SettingsRow {/* Style / Color / Quick Colors ... */} /BoxedList組件本身不接觸存儲層只暴露granularity與onGranularityChange兩個 props真正的讀寫邏輯在TTSPanel中。3.2 TTSPanel本地 state 值監(jiān)聽 useEffect 持久化components/settings/TTSPanel.tsx 用本地 state 承載選擇值const [ttsHighlightGranularity, setTtsHighlightGranularity] useStateTTSHighlightGranularity( viewSettings.ttsHighlightGranularity ?? word, );用戶切換后通過值監(jiān)聽useEffect調(diào)用saveViewSettings(envConfig, bookKey, ttsHighlightGranularity, ttsHighlightGranularity, false, false)持久化TTSPanel.tsx。這里刻意模仿了ttsMediaMetadata的處理方式saveViewSettings最后兩個false參數(shù)表示不觸發(fā)書內(nèi)重排、不強(qiáng)制刷新因此更改粒度不會干擾正在進(jìn)行的閱讀布局。useEffect(() { if (ttsHighlightGranularity viewSettings.ttsHighlightGranularity) return; saveViewSettings( envConfig, bookKey, ttsHighlightGranularity, ttsHighlightGranularity, false, false, ); }, [ttsHighlightGranularity]);handleReset中同樣將ttsHighlightGranularity納入resetToDefaults保證重置為默認(rèn)時該字段一并恢復(fù)為word。四、控制器如何感知該設(shè)置setHighlightGranularityTTSController通過setHighlightGranularity(granularity)方法學(xué)習(xí)用戶選擇內(nèi)部存入私有字段#highlightGranularityTTSController.tssetHighlightGranularity(granularity: TTSHighlightGranularity) { this.#highlightGranularity granularity; }調(diào)用方是 app/reader/hooks/useTTSControl.ts存在兩條注入路徑控制器創(chuàng)建時在init()流程中緊挨著updateHighlightOptions(...)調(diào)用ttsController.setHighlightGranularity(viewSettings.ttsHighlightGranularity ?? word)useTTSControl.ts運行時值變化通過監(jiān)聽viewSettings?.ttsHighlightGranularity的useEffect再次調(diào)用setHighlightGranularityuseTTSControl.ts因此用戶在播放中修改設(shè)置也能即時生效。測試側(cè)的鏡像要求由于useTTSControl在創(chuàng)建路徑上無條件調(diào)用setHighlightGranularity凡是 mockTTSController的測試如tests/hooks/useTTSControl.test.tsx必須在 mock 對象中包含setHighlightGranularity: vi.fn()否則 speak 路徑會因調(diào)用不存在的方法而拋出異常導(dǎo)致 position/state 事件無法派發(fā)。這是接入該設(shè)置時最容易踩的坑之一倉庫中兩處 mockuseTTSControl.test.tsx的 L158 與 L1126均按此補(bǔ)齊。五、引擎能力差異逐詞高亮只在 Edge TTS 上發(fā)生5.1 TTSCapabilities.wordBoundaries 能力位是否支持逐詞高亮由各 TTS 客戶端上報的TTSCapabilities.wordBoundaries決定該能力位定義在 services/tts/TTSClient.tsexport interface TTSCapabilities { // Reports word-boundary timings during playback: the controller highlights // word-by-word and suppresses the sentence highlight. wordBoundaries: boolean; mediaClock: boolean; gapControl: boolean; liveRateChange: boolean; continuousTimeline: boolean; textHighlight: boolean; }各客戶端的實際上報值客戶端wordBoundaries說明源碼位置Edge TTSBufferedTTSClienttrue唯一具備詞邊界能力的客戶端BufferedTTSClient.tsWeb SpeechWebSpeechClientfalse直接發(fā)聲引擎無音頻時鐘也無詞邊界WebSpeechClient.tsNative TTSAndroid / iOSfalse同上系統(tǒng)級直接發(fā)聲NativeTTSClient.tsMedia Overlay出版方旁白false按元素整段計時無詞級插值mediaOverlay/MediaOverlayClient.ts因此事實語義是逐詞高亮只在 Edge TTS 上發(fā)生Web / Native / Media Overlay 一律按句子高亮。由于每個引擎都支持句子高亮是系統(tǒng)假設(shè)用戶在非 Edge 引擎上選擇word會自然回退為句子高亮界面上無需任何額外提示或禁用邏輯。六、核心TTSController 中的雙點門控這是本設(shè)置最關(guān)鍵的實現(xiàn)細(xì)節(jié)。粒度判斷沒有收斂到一個 helper而是分散在TTSController的兩個不同位置各自承擔(dān)不同職責(zé)。6.1 門控點一dispatchSpeakMark 中的句子高亮抑制dispatchSpeakMark(mark)負(fù)責(zé)在句子 mark 派發(fā)時讓 foliate 的setMark繪制句子高亮TTSController.ts。圍繞setMark調(diào)用控制器計算#suppressMarkHighlightthis.#suppressMarkHighlight this.ttsClient.getCapabilities().wordBoundaries this.#highlightGranularity word; const range this.#getTts()?.setMark(mark.name); this.#suppressMarkHighlight false;當(dāng)引擎支持詞邊界 且 用戶選擇word時setMark觸發(fā)的句子高亮回調(diào)#getHighlighter()中if (this.#suppressMarkHighlight) return;見 TTSController.ts會被抑制——否則頁面會在第一個詞邊界到來之前先整句閃一下破壞逐詞跟隨的視覺效果。而當(dāng)用戶選擇sentence時#suppressMarkHighlight恒為false句子高亮在 mark 派發(fā)時正常繪制這正是句子模式期望的行為。注意該標(biāo)志只在setMark這個同步調(diào)用期間為真詞級繪制dispatchSpeakWord和暫停態(tài)導(dǎo)航不受影響。6.2 門控點二prepareSpeakWords 的提前返回prepareSpeakWords(words)是詞級高亮的入口由報告詞邊界的客戶端生產(chǎn)環(huán)境即 EdgeTTSClient在每個 chunk 邊界回調(diào)。它的開頭有一個僅按粒度判斷的早退TTSController.tsprepareSpeakWords(words: string[]) { if (!this.#speakWordsArmed) return; // User forced sentence-level highlighting: the sentence highlight was drawn // at mark dispatch (not suppressed), so theres nothing to do here. if (this.#highlightGranularity sentence) return; ... }這里有一個刻意為之的設(shè)計只按#highlightGranularity sentence判斷不再疊加supportsWordBoundaries()檢查。原因記錄在記憶文檔中并有明確的測試約束生產(chǎn)環(huán)境中prepareSpeakWords只被 EdgeTTSClient 調(diào)用此時邊界必然存在但tts-controller.test.ts會用 web 客戶端wordBoundaries false直接調(diào)用prepareSpeakWords并期望它正常進(jìn)入詞級高亮邏輯見 tts-controller.test.ts 的 prepareSpeakWords immediately highlights the first word 用例若在早退條件里加入supportsWordBoundaries()檢查這些既有測試會全部失敗。換言之抑制句子高亮門控一必須同時看能力與用戶選擇而詞級高亮的入口門控二只服從用戶選擇。兩份職責(zé)分離后即使客戶端上報了詞邊界只要用戶選了sentence詞模式就永不啟動反之即便客戶端沒有詞邊界prepareSpeakWords被直接調(diào)用時也能按words.length 0的分支走句子回退。6.3 雙門控的聯(lián)動效果矩陣用戶選擇引擎能力mark 派發(fā)時句子高亮prepareSpeakWords 行為最終效果wordEdge有詞邊界抑制詞級高亮首詞立即繪制逐詞跟隨wordWeb / Native / Overlay無詞邊界不抑制能力不滿足生產(chǎn)環(huán)境不調(diào)用直接調(diào)用時words為空 → 繪制整句作為回退句子高亮自然回退sentence任意不抑制提前返回詞模式不啟動句子高亮七、詞級高亮繪制細(xì)節(jié)與視圖跟隨當(dāng)門控放行后詞級高亮走完整的三段式流水線均位于 TTSController.tsprepareSpeakWordsL2108-L2131以#getTts()?.getLastRange()為基準(zhǔn)句范圍用rangeTextExcludingInert(range)提取文本computeWordOffsets(matchText, words)計算每個詞相對句首的偏移若words.length 0本 chunk 無詞邊界則將此前被抑制的句子高亮補(bǔ)畫回來作為兜底否則立即dispatchSpeakWord(0)高亮第一個詞杜絕句子先閃一下。dispatchSpeakWord(index)L2133-L2156用getTextSubRange(base, offset.start, offset.end)從句子范圍切出詞子范圍繪制到 overlayer并派發(fā)tts-highlight-word事件與tts-positionword信號——后者讓視圖在詞跨頁邊界時跟隨到下一頁而不必等下一個句子的 mark。reapplyCurrentHighlight()L1992-L2013翻頁/重渲染后重畫高亮。詞模式播放中會重畫當(dāng)前詞而非整句而在詞模式句子 mark 已派發(fā)但首個詞邊界尚未到達(dá)的間隙刻意不畫任何內(nèi)容避免整句閃爍。配套的 CFI 查詢getCurrentHighlightCfi()L2050-L2060在詞模式返回當(dāng)前詞的 CFI供回到朗讀位置按鈕等消費方使用——當(dāng)句子跨頁時詞的位置才是頁面上真實可見的錨點。八、測試驗證行為即契約該設(shè)置的語義由tests/services/tts-controller.test.ts 中完整的 word highlighting 測試套件固化覆蓋了prepareSpeakWords立即高亮第一個詞、不出現(xiàn)句子閃爍L653-L662無詞邊界words為空時回退整句高亮L664-L673dispatchSpeakWord高亮當(dāng)前詞的子范圍并使用tts-highlight鍵L675-L688回退后亂序派發(fā)詞索引仍正確對齊L690-L701一次性 markname -1不參與詞高亮L703-L709新 mark 派發(fā)會清空此前準(zhǔn)備好的詞L711-L725首詞不匹配時不高亮、后續(xù)詞仍對齊L727-L739reapplyCurrentHighlight在詞模式重畫當(dāng)前詞、非詞模式重畫整句L741-L753粒度門控setHighlightGranularity(sentence)后即便客戶端有詞邊界也不進(jìn)入詞模式L806-L817word默認(rèn)則正常逐詞高亮L819-L827。TTSPanel.test.tsx的 fixture 中也將ttsHighlightGranularity: word作為默認(rèn)視圖設(shè)置的一部分tests/components/settings/TTSPanel.test.tsx保證 UI 層與控制器層的默認(rèn)語義一致。九、關(guān)聯(lián)實現(xiàn)與注意事項小結(jié)與ttsHighlightOptions的關(guān)系粒度只決定高亮跟隨到詞還是句子高亮的樣式Highlighter/Underline 等與顏色由ttsHighlightOptions單獨控制兩者在#getHighlighter()中匯合樣式、顏色用于 overlayer 繪制粒度決定繪制時機(jī)與范圍。與ttsMediaMetadata的區(qū)分ttsMediaMetadata控制的是媒體會話鎖屏/通知欄中展示的進(jìn)度粒度sentence/paragraph/chapter與頁面高亮粒度相互獨立但持久化方式saveViewSettings(..., false, false) 值監(jiān)聽useEffect完全一致。中文等 CJK 書籍的切分文本迭代器粒度TTSGranularity在 TTSController.ts 中會按view.language.isCJK自動選擇sentence并受客戶端getGranularities()約束這與面向用戶的高亮粒度設(shè)置是兩層概念閱讀 CJK 書籍時句子的迭代邊界可能更粗但高亮粒度選擇仍按用戶設(shè)置生效。代碼接入檢查清單來自記憶文檔與倉庫測試的共同約束① mockTTSController必須帶setHighlightGranularity: vi.fn()② 修改粒度不得觸發(fā)重排saveViewSettings末兩位參數(shù)為false③ 新增有詞邊界能力的引擎時需同步確認(rèn)dispatchSpeakMark的抑制條件與prepareSpeakWords的早退條件依舊成立。綜上所述Readest 的 TTS 高亮粒度設(shè)置是一個典型的設(shè)置簡單、運行時門控精細(xì)的功能數(shù)據(jù)側(cè)僅一個word | sentence枚舉UI 側(cè)一行下拉框加值監(jiān)聽持久化但運行時的正確性依賴TTSController中**抑制能力 ∩ 選擇與早退僅選擇**這兩處語義不同的門控協(xié)同并由tts-controller.test.ts的測試套件將這份契約固化下來?!久赓M下載鏈接】readestReadest is a modern, feature-rich ebook reader designed for avid readers offering seamless cross-platform access, powerful tools, and an intuitive interface to elevate your reading experience.項目地址: https://gitcode.com/gh_mirrors/re/readest創(chuàng)作聲明:本文部分內(nèi)容由AI輔助生成(AIGC),僅供參考