多彩编程 多彩编程MZPH · CODE BLOG
ARTICLE DETAIL

文章详情

深耕前端与后端开发技术的一线实战笔记与踩坑复盘。

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

Readest TTS 高亮粒度(逐词 / 逐句)设置:从设置面板到 TTSController 双点门控的实现全解 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 面板中GranularityWord / Sentence设置从数据模型、UI 持久化到TTSController运行时门控的完整链路。你将掌握该设置的数据类型与默认值定义在哪里、如何在设置面板读写并持久化、不同 TTS 引擎Edge / Web / Native / Media Overlay的能力差异如何影响高亮回退语义以及TTSController中dispatchSpeakMark抑制与prepareSpeakWords提前返回这两个关键门控点为何必须分开实现。一、功能总览一个设置两档高亮粒度Readest 的朗读Read Aloud功能在阅读过程中会把正在朗读的文本在页面上高亮出来。用户可以在设置 → TTS → TTS Highlighting分组中通过第一行的Granularity下拉框选择高亮跟随的粒度Word默认逐词高亮语音读到哪个词页面就高亮哪个词Sentence整句高亮语音进入某个句子后该句整体保持高亮直到下一句开始。该下拉框位于 Style 选择之前属于TTSHighlightStyleEditor这个高亮样式编辑器的一部分同一分组还包含 StyleHighlighter / Underline / Strikethrough / Squiggly / Outline以及 Color 与 Quick Colors 调色板。一个关键前提是逐词高亮并非所有语音引擎都能提供。Readest 假设每个引擎都支持句子级高亮因此当用户选择了word而当前引擎不提供词边界word boundary信息时系统会自动回退为句子级高亮不会出现选不到、报错的情况。二、数据模型与默认值该设置对应的字段是ttsHighlightGranularity: TTSHighlightGranularity挂在视图设置中的 TTS 配置块上。类型定义在 services/tts/types.tsexport 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 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 值监听 useEffect 持久化components/settings/TTSPanel.tsx 用本地 state 承载选择值const [ttsHighlightGranularity, setTtsHighlightGranularity] useStateTTSHighlightGranularity( 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。四、控制器如何感知该设置setHighlightGranularityTTSController通过setHighlightGranularity(granularity)方法学习用户选择内部存入私有字段#highlightGranularityTTSController.tssetHighlightGranularity(granularity: TTSHighlightGranularity) { this.#highlightGranularity granularity; }调用方是 app/reader/hooks/useTTSControl.ts存在两条注入路径控制器创建时在init()流程中紧挨着updateHighlightOptions(...)调用ttsController.setHighlightGranularity(viewSettings.ttsHighlightGranularity ?? word)useTTSControl.ts运行时值变化通过监听viewSettings?.ttsHighlightGranularity的useEffect再次调用setHighlightGranularityuseTTSControl.ts因此用户在播放中修改设置也能即时生效。测试侧的镜像要求由于useTTSControl在创建路径上无条件调用setHighlightGranularity凡是 mockTTSController的测试如tests/hooks/useTTSControl.test.tsx必须在 mock 对象中包含setHighlightGranularity: vi.fn()否则 speak 路径会因调用不存在的方法而抛出异常导致 position/state 事件无法派发。这是接入该设置时最容易踩的坑之一仓库中两处 mockuseTTSControl.test.tsx的 L158 与 L1126均按此补齐。五、引擎能力差异逐词高亮只在 Edge TTS 上发生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直接发声引擎无音频时钟也无词边界WebSpeechClient.tsNative TTSAndroid / iOSfalse同上系统级直接发声NativeTTSClient.tsMedia 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调用控制器计算#suppressMarkHighlightthis.#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.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; ... }这里有一个刻意为之的设计只按#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.tsprepareSpeakWordsL2108-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-positionword信号——后者让视图在词跨页边界时跟随到下一页而不必等下一个句子的 mark。reapplyCurrentHighlight()L1992-L2013翻页/重渲染后重画高亮。词模式播放中会重画当前词而非整句而在词模式句子 mark 已派发但首个词边界尚未到达的间隙刻意不画任何内容避免整句闪烁。配套的 CFI 查询getCurrentHighlightCfi()L2050-L2060在词模式返回当前词的 CFI供回到朗读位置按钮等消费方使用——当句子跨页时词的位置才是页面上真实可见的锚点。八、测试验证行为即契约该设置的语义由tests/services/tts-controller.test.ts 中完整的 word highlighting 测试套件固化覆盖了prepareSpeakWords立即高亮第一个词、不出现句子闪烁L653-L662无词边界words为空时回退整句高亮L664-L673dispatchSpeakWord高亮当前词的子范围并使用tts-highlight键L675-L688回退后乱序派发词索引仍正确对齐L690-L701一次性 markname -1不参与词高亮L703-L709新 mark 派发会清空此前准备好的词L711-L725首词不匹配时不高亮、后续词仍对齐L727-L739reapplyCurrentHighlight在词模式重画当前词、非词模式重画整句L741-L753粒度门控setHighlightGranularity(sentence)后即便客户端有词边界也不进入词模式L806-L817word默认则正常逐词高亮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),仅供参考
返回列表