Readest TTS 高亮粒度(逐词 / 逐句)设置:从设置面板到 TTSController 双点门控的实现全解
2026/9/21 19:27:02 网站建设 项目流程

Readest TTS 高亮粒度(逐词 / 逐句)设置:从设置面板到 TTSController 双点门控的实现全解

【免费下载链接】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

导读

本文以 Readest 仓库中的apps/readest-app/.claude/memory/tts-highlight-granularity-setting.md记忆文档为骨架,完整梳理 "TTS Highlighting" 面板中Granularity(Word / Sentence)设置从数据模型、UI 持久化到TTSController运行时门控的完整链路。你将掌握:该设置的数据类型与默认值定义在哪里、如何在设置面板读写并持久化、不同 TTS 引擎(Edge / Web / Native / Media Overlay)的能力差异如何影响高亮回退语义,以及TTSControllerdispatchSpeakMark抑制与prepareSpeakWords提前返回这两个关键门控点为何必须分开实现。

一、功能总览:一个设置,两档高亮粒度

Readest 的朗读(Read Aloud)功能在阅读过程中会把正在朗读的文本在页面上高亮出来。用户可以在设置 → TTS → "TTS Highlighting"分组中,通过第一行的Granularity下拉框选择高亮跟随的粒度:

  • Word(默认):逐词高亮,语音读到哪个词,页面就高亮哪个词;
  • Sentence:整句高亮,语音进入某个句子后,该句整体保持高亮直到下一句开始。

该下拉框位于 Style 选择之前,属于TTSHighlightStyleEditor这个高亮样式编辑器的一部分(同一分组还包含 Style:Highlighter / Underline / Strikethrough / Squiggly / Outline,以及 Color 与 Quick Colors 调色板)。

一个关键前提是:逐词高亮并非所有语音引擎都能提供。Readest 假设每个引擎都支持句子级高亮,因此当用户选择了word而当前引擎不提供词边界(word boundary)信息时,系统会自动回退为句子级高亮,不会出现"选不到、报错"的情况。

二、数据模型与默认值

该设置对应的字段是ttsHighlightGranularity: TTSHighlightGranularity,挂在视图设置中的 TTS 配置块上。

类型定义在 services/tts/types.ts:

export type TTSGranularity = 'sentence' | 'word'; export type TTSHighlightGranularity = 'word' | 'sentence';

注意区分两个看似相近的类型:

  • TTSGranularity用于 foliate-js 的TTS文本迭代器,决定段落/句子的切分方式(在#initTTSForSection中会根据书语言是否为 CJK 以及客户端getGranularities()的支持情况决定);
  • TTSHighlightGranularity面向用户的显示粒度,决定高亮是逐词绘制还是整句绘制,即本文主角。

默认值为'word',定义在 services/constants.ts 的DEFAULT_TTS_CONFIG中,与ttsHighlightOptions(默认{ style: 'highlight', color: '#808080' })、ttsMediaMetadatattsPlayerStyle等同组:

ttsHighlightOptions: { style: 'highlight', color: '#808080' }, ttsHighlightGranularity: 'word', ttsMediaMetadata: 'sentence', ttsPlayerStyle: 'full', ttsSkipInlineAnnotations: false,

用户可选项严格限定为word/sentence两个值;TTSPanel读取时以viewSettings.ttsHighlightGranularity ?? 'word'兜底,useTTSControl创建控制器时也以viewSettings.ttsHighlightGranularity ?? 'word'作为初始值,保证旧数据缺省时按逐词模式工作。

三、设置面板 UI 与持久化链路

3.1 下拉框组件

Granularity 下拉框由 components/settings/theme/TTSHighlightStyleEditor.tsx 渲染,是整个 "TTS Highlighting"BoxedList的第一行(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>

组件本身不接触存储层,只暴露granularityonGranularityChange两个 props,真正的读写逻辑在TTSPanel中。

3.2 TTSPanel:本地 state + 值监听 useEffect 持久化

components/settings/TTSPanel.tsx 用本地 state 承载选择值:

const [ttsHighlightGranularity, setTtsHighlightGranularity] = useState<TTSHighlightGranularity>( viewSettings.ttsHighlightGranularity ?? 'word', );

用户切换后,通过值监听useEffect调用saveViewSettings(envConfig, bookKey, 'ttsHighlightGranularity', ttsHighlightGranularity, false, false)持久化(TTSPanel.tsx)。这里刻意模仿了ttsMediaMetadata的处理方式:saveViewSettings最后两个false参数表示不触发书内重排、不强制刷新,因此更改粒度不会干扰正在进行的阅读布局。

useEffect(() => { if (ttsHighlightGranularity === viewSettings.ttsHighlightGranularity) return; saveViewSettings( envConfig, bookKey, 'ttsHighlightGranularity', ttsHighlightGranularity, false, false, ); }, [ttsHighlightGranularity]);

handleReset中同样将ttsHighlightGranularity纳入resetToDefaults,保证"重置为默认"时该字段一并恢复为word

四、控制器如何感知该设置:setHighlightGranularity

TTSController通过setHighlightGranularity(granularity)方法学习用户选择,内部存入私有字段#highlightGranularity(TTSController.ts):

setHighlightGranularity(granularity: TTSHighlightGranularity) { this.#highlightGranularity = granularity; }

调用方是 app/reader/hooks/useTTSControl.ts,存在两条注入路径

  1. 控制器创建时:在init()流程中紧挨着updateHighlightOptions(...)调用ttsController.setHighlightGranularity(viewSettings.ttsHighlightGranularity ?? 'word')(useTTSControl.ts);
  2. 运行时值变化:通过监听viewSettings?.ttsHighlightGranularityuseEffect再次调用setHighlightGranularity(useTTSControl.ts),因此用户在播放中修改设置也能即时生效。

测试侧的镜像要求

由于useTTSControl在创建路径上无条件调用setHighlightGranularity,凡是 mockTTSController的测试(如tests/hooks/useTTSControl.test.tsx)必须在 mock 对象中包含setHighlightGranularity: vi.fn(),否则 speak 路径会因调用不存在的方法而抛出异常,导致 position/state 事件无法派发。这是接入该设置时最容易踩的坑之一,仓库中两处 mock(useTTSControl.test.tsx的 L158 与 L1126)均按此补齐。

五、引擎能力差异:逐词高亮只在 Edge TTS 上发生

5.1 TTSCapabilities.wordBoundaries 能力位

是否支持逐词高亮,由各 TTS 客户端上报的TTSCapabilities.wordBoundaries决定,该能力位定义在 services/tts/TTSClient.ts:

export 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 TTS(BufferedTTSClienttrue唯一具备词边界能力的客户端BufferedTTSClient.ts
Web Speech(WebSpeechClientfalse直接发声引擎,无音频时钟也无词边界WebSpeechClient.ts
Native TTS(Android / iOS)false同上,系统级直接发声NativeTTSClient.ts
Media Overlay(出版方旁白)false按元素整段计时,无词级插值mediaOverlay/MediaOverlayClient.ts

因此事实语义是:逐词高亮只在 Edge TTS 上发生;Web / Native / Media Overlay 一律按句子高亮。由于"每个引擎都支持句子高亮"是系统假设,用户在非 Edge 引擎上选择word会自然回退为句子高亮,界面上无需任何额外提示或禁用逻辑。

六、核心:TTSController 中的双点门控

这是本设置最关键的实现细节。粒度判断没有收敛到一个 helper,而是分散在TTSController的两个不同位置,各自承担不同职责。

6.1 门控点一:dispatchSpeakMark 中的句子高亮抑制

dispatchSpeakMark(mark)负责在句子 mark 派发时,让 foliate 的setMark绘制句子高亮(TTSController.ts)。围绕setMark调用,控制器计算#suppressMarkHighlight

this.#suppressMarkHighlight = this.ttsClient.getCapabilities().wordBoundaries && this.#highlightGranularity === 'word'; const range = this.#getTts()?.setMark(mark.name); this.#suppressMarkHighlight = false;

引擎支持词边界 且 用户选择word时,setMark触发的句子高亮回调(#getHighlighter()if (this.#suppressMarkHighlight) return;,见 TTSController.ts)会被抑制——否则页面会在第一个词边界到来之前先整句闪一下,破坏逐词跟随的视觉效果。

而当用户选择sentence时,#suppressMarkHighlight恒为false,句子高亮在 mark 派发时正常绘制,这正是句子模式期望的行为。注意该标志只在setMark这个同步调用期间为真,词级绘制(dispatchSpeakWord)和暂停态导航不受影响。

6.2 门控点二:prepareSpeakWords 的提前返回

prepareSpeakWords(words)是词级高亮的入口,由报告词边界的客户端(生产环境即 EdgeTTSClient)在每个 chunk 边界回调。它的开头有一个仅按粒度判断的早退(TTSController.ts):

prepareSpeakWords(words: string[]) { if (!this.#speakWordsArmed) return; // User forced sentence-level highlighting: the sentence highlight was drawn // at mark dispatch (not suppressed), so there's nothing to do here. if (this.#highlightGranularity === 'sentence') return; ... }

这里有一个刻意为之的设计:只按#highlightGranularity === 'sentence'判断,不再叠加supportsWordBoundaries()检查。原因记录在记忆文档中,并有明确的测试约束:

  • 生产环境中prepareSpeakWords只被 EdgeTTSClient 调用(此时边界必然存在);
  • tts-controller.test.ts用 web 客户端(wordBoundaries = false)直接调用prepareSpeakWords,并期望它正常进入词级高亮逻辑(见 tts-controller.test.ts 的 "prepareSpeakWords immediately highlights the first word" 用例);
  • 若在早退条件里加入supportsWordBoundaries()检查,这些既有测试会全部失败。

换言之:抑制句子高亮(门控一)必须同时看能力与用户选择,而词级高亮的入口(门控二)只服从用户选择。两份职责分离后,即使客户端上报了词边界,只要用户选了sentence,词模式就永不启动;反之,即便客户端没有词边界,prepareSpeakWords被直接调用时也能按words.length === 0的分支走句子回退。

6.3 双门控的联动效果矩阵

用户选择引擎能力mark 派发时句子高亮prepareSpeakWords 行为最终效果
wordEdge(有词边界)抑制词级高亮,首词立即绘制逐词跟随
wordWeb / Native / Overlay(无词边界)不抑制(能力不满足)生产环境不调用;直接调用时words为空 → 绘制整句作为回退句子高亮(自然回退)
sentence任意不抑制提前返回,词模式不启动句子高亮

七、词级高亮绘制细节与视图跟随

当门控放行后,词级高亮走完整的三段式流水线(均位于 TTSController.ts):

  1. prepareSpeakWords(L2108-L2131):以#getTts()?.getLastRange()为基准句范围,用rangeTextExcludingInert(range)提取文本,computeWordOffsets(matchText, words)计算每个词相对句首的偏移;若words.length === 0(本 chunk 无词边界),则将此前被抑制的句子高亮补画回来作为兜底;否则立即dispatchSpeakWord(0)高亮第一个词,杜绝"句子先闪一下"。
  2. dispatchSpeakWord(index)(L2133-L2156):用getTextSubRange(base, offset.start, offset.end)从句子范围切出词子范围,绘制到 overlayer,并派发tts-highlight-word事件与tts-position'word')信号——后者让视图在词跨页边界时跟随到下一页,而不必等下一个句子的 mark。
  3. reapplyCurrentHighlight()(L1992-L2013):翻页/重渲染后重画高亮。词模式播放中会重画当前词而非整句;而在词模式"句子 mark 已派发但首个词边界尚未到达"的间隙,刻意不画任何内容,避免整句闪烁。

配套的 CFI 查询getCurrentHighlightCfi()(L2050-L2060)在词模式返回当前词的 CFI,供"回到朗读位置"按钮等消费方使用——当句子跨页时,词的位置才是页面上真实可见的锚点。

八、测试验证:行为即契约

该设置的语义由tests/services/tts-controller.test.ts 中完整的 "word highlighting" 测试套件固化,覆盖了:

  • prepareSpeakWords立即高亮第一个词、不出现句子闪烁(L653-L662);
  • 无词边界(words为空)时回退整句高亮(L664-L673);
  • dispatchSpeakWord高亮当前词的子范围并使用tts-highlight键(L675-L688);
  • 回退后乱序派发词索引仍正确对齐(L690-L701);
  • 一次性 mark(name === '-1')不参与词高亮(L703-L709);
  • 新 mark 派发会清空此前准备好的词(L711-L725);
  • 首词不匹配时不高亮、后续词仍对齐(L727-L739);
  • reapplyCurrentHighlight在词模式重画当前词、非词模式重画整句(L741-L753);
  • 粒度门控setHighlightGranularity('sentence')后即便客户端有词边界也不进入词模式(L806-L817);'word'(默认)则正常逐词高亮(L819-L827)。

TTSPanel.test.tsx的 fixture 中也将ttsHighlightGranularity: 'word'作为默认视图设置的一部分(tests/components/settings/TTSPanel.test.tsx),保证 UI 层与控制器层的默认语义一致。

九、关联实现与注意事项小结

  • ttsHighlightOptions的关系:粒度只决定"高亮跟随到词还是句子",高亮的样式(Highlighter/Underline 等)与颜色由ttsHighlightOptions单独控制,两者在#getHighlighter()中汇合(样式、颜色用于 overlayer 绘制,粒度决定绘制时机与范围)。
  • ttsMediaMetadata的区分ttsMediaMetadata控制的是媒体会话(锁屏/通知栏)中展示的进度粒度(sentence/paragraph/chapter),与页面高亮粒度相互独立,但持久化方式(saveViewSettings(..., false, false)+ 值监听useEffect)完全一致。
  • 中文等 CJK 书籍的切分:文本迭代器粒度(TTSGranularity)在 TTSController.ts 中会按view.language.isCJK自动选择sentence,并受客户端getGranularities()约束;这与面向用户的高亮粒度设置是两层概念,阅读 CJK 书籍时句子的迭代边界可能更粗,但高亮粒度选择仍按用户设置生效。
  • 代码接入检查清单(来自记忆文档与仓库测试的共同约束):① mockTTSController必须带setHighlightGranularity: vi.fn();② 修改粒度不得触发重排(saveViewSettings末两位参数为false);③ 新增"有词边界"能力的引擎时,需同步确认dispatchSpeakMark的抑制条件与prepareSpeakWords的早退条件依旧成立。

综上所述,Readest 的 TTS 高亮粒度设置是一个典型的"设置简单、运行时门控精细"的功能:数据侧仅一个'word' | 'sentence'枚举,UI 侧一行下拉框加值监听持久化,但运行时的正确性依赖TTSController中**抑制(能力 ∩ 选择)早退(仅选择)**这两处语义不同的门控协同,并由tts-controller.test.ts的测试套件将这份契约固化下来。

【免费下载链接】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

创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询