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)的能力差异如何影响高亮回退语义,以及TTSController中dispatchSpeakMark抑制与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' })、ttsMediaMetadata、ttsPlayerStyle等同组:
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>组件本身不接触存储层,只暴露granularity与onGranularityChange两个 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,存在两条注入路径:
- 控制器创建时:在
init()流程中紧挨着updateHighlightOptions(...)调用ttsController.setHighlightGranularity(viewSettings.ttsHighlightGranularity ?? 'word')(useTTSControl.ts); - 运行时值变化:通过监听
viewSettings?.ttsHighlightGranularity的useEffect再次调用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(BufferedTTSClient) | true | 唯一具备词边界能力的客户端 | BufferedTTSClient.ts |
Web Speech(WebSpeechClient) | false | 直接发声引擎,无音频时钟也无词边界 | 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 行为 | 最终效果 |
|---|---|---|---|---|
word | Edge(有词边界) | 抑制 | 词级高亮,首词立即绘制 | 逐词跟随 |
word | Web / Native / Overlay(无词边界) | 不抑制(能力不满足) | 生产环境不调用;直接调用时words为空 → 绘制整句作为回退 | 句子高亮(自然回退) |
sentence | 任意 | 不抑制 | 提前返回,词模式不启动 | 句子高亮 |
七、词级高亮绘制细节与视图跟随
当门控放行后,词级高亮走完整的三段式流水线(均位于 TTSController.ts):
prepareSpeakWords(L2108-L2131):以#getTts()?.getLastRange()为基准句范围,用rangeTextExcludingInert(range)提取文本,computeWordOffsets(matchText, words)计算每个词相对句首的偏移;若words.length === 0(本 chunk 无词边界),则将此前被抑制的句子高亮补画回来作为兜底;否则立即dispatchSpeakWord(0)高亮第一个词,杜绝"句子先闪一下"。dispatchSpeakWord(index)(L2133-L2156):用getTextSubRange(base, offset.start, offset.end)从句子范围切出词子范围,绘制到 overlayer,并派发tts-highlight-word事件与tts-position('word')信号——后者让视图在词跨页边界时跟随到下一页,而不必等下一个句子的 mark。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 书籍时句子的迭代边界可能更粗,但高亮粒度选择仍按用户设置生效。 - 代码接入检查清单(来自记忆文档与仓库测试的共同约束):① mock
TTSController必须带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),仅供参考