Readest TTS 语速刻度尺(SpeedRuler / TickRuler)设计与实现深度解析
2026/9/21 20:26:55 网站建设 项目流程
  • 桌面应用
  • 跨平台
  • 前端

【免费下载链接】readest

Readest 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 仓库中记录 TTS 语速调节功能演进与实现的开发记忆文档为核心骨架,系统讲解「TTS 播放器速度子视图」从固定语速 chips(0.75×/0.8×/0.85× 预设按钮)演进为「播客风格刻度尺滑块」的全过程。文章覆盖SpeedRuler与泛化组件TickRuler的完整实现(0.5×–3× 范围、0.05 步进、不可见原生 range 输入、拖拽预览与提交时机、键盘 500ms 防抖、浮点精度陷阱),并结合测试用例、章节拖动预览、缓冲时长水合等关联改动,帮助读者掌握 Readest 中这类「本地预览 + 松手提交」交互控件的源码级实现思路。

需求背景:从固定语速预设到刻度尺滑块

Readest 的 TTS(文本朗读)播放器最初采用**固定语速预设按钮(chips)**方案。用户反馈(Issue #5078)希望提供 0.75× 到 0.9× 之间的语速档位,PR #5157 随即在SPEED_PRESETS中加入了 0.8×/0.85× 两个档位。然而在 2026-07-17 的评审中,chrox 提出参照播客应用录屏中的ruler 样式滑块重新设计:深色面板、0.5–3.0 的刻度梳(tick comb)、每 0.5 处显示暗淡标签、最活跃刻度上方突出当前值的明亮标签。

最终方案(PR #5162,9 个提交,关闭 Issue #5101)是:SpeedRuler同时取代了评审期间 main 分支上叠加的 bubbleSlider与原有的 chips,而通用的Slider组件保留给 footerbar 面板继续使用。这一决策让速度调节的交互密度显著提升——原本每档一个按钮,现在一个刻度尺即可覆盖 0.5×–3× 全范围。

SpeedRuler:0.5×–3× 全范围刻度尺

SpeedRuler位于 SpeedRuler.tsx,是一个薄封装组件,将全部参数交给泛化组件TickRuler渲染:

export const SPEED_MIN = 0.5; export const SPEED_MAX = 3.0; export const SPEED_STEP = 0.05; const SPEED_MARKS = [0.5, 1.0, 1.5, 2.0, 2.5, 3.0]; export const formatRate = (rate: number) => `${parseFloat(rate.toFixed(2))}×`;

关键设计决策值得注意:

  • 步进 0.05:范围 0.5×–3× 之间共(3.0 - 0.5) / 0.05 = 50个刻度。相较录屏参考视频中 0.1 的步进更细,关键原因是保留旧预设全部可达——0.75×、1.25×、1.75× 这些历史档位都是 0.05 的整数倍,如果按 0.1 步进则 0.75×、1.25× 将不可达,会造成旧用户配置失效。
  • formatRate处理浮点显示0.75 + 0.05这类浮点累加可能产生 0.8000000000000001 之类的值,parseFloat(rate.toFixed(2))先四舍五入到两位小数再转回数字,保证界面显示如0.8×而非0.8000000000000001×
  • formatRate函数同时被 TTSMiniPlayer.tsx 与 TTSPlayerSheet.tsx 导入复用(后者在速度按钮上展示当前语速formatRate(rate))。

在播放器底部弹层(TTS player sheet)中,SpeedRuler被挂载在speed子视图下,onSelect回调指向handleSelectRate(见 TTSPlayerSheet.tsx)。该处理器将新语速写入按书视图设置(book-scoped view settings)并同步到全局设置存储,随后调用onSetRate通知 TTS 引擎。这意味着每次提交都是一次持久化 + 语速变更生效,也直接决定了「拖拽本地预览、松手才提交」这一交互设计——如果拖动过程中每过一个刻度就提交一次,TTS 引擎会被反复重启。

TickRuler:泛化刻度尺的核心实现

SpeedRuler只负责配置,真正的绘制与交互逻辑在 TickRuler.tsx。它的 props 设计刻意做成通用接口,使同一套刻度尺既服务语速,也服务句间/段间停顿设置:

type TickRulerProps = { min: number; max: number; step: number; marks: number[]; // 获得暗淡标签 + 更高刻度的值 value: number; ariaLabel: string; formatValue: (value: number) => string; // 活动刻度上方的明亮标签 formatMark: (value: number) => string; // 刻度标签 onSelect: (value: number) => void; };

刻度梳(tick comb)的绘制

刻度列表通过useMemo生成:Array.from({ length: Math.round(range / step) + 1 }, ...),并对每个刻度做Math.round((min + i * step) * 100) / 100的取整,规避浮点累加误差。渲染时分三层:

  1. 标记标签层(高度 h-5):marks中的值以暗淡样式(text-base-content/50)显示,通过left: toPct(mark)%定位;
  2. 刻度梳层(高度 h-7):每根竖线按是否活动/是否标记分为三种样式——活动刻度h-7 w-0.5(最粗最高)、标记刻度h-5 w-0.5base-content/40)、普通刻度h-3.5 w-pxbase-content/25);
  3. 不可见交互层:铺满整个区域的opacity-0原生<input type='range'>

浮点陷阱:2.0 - 1.8 === 0.1999...

这是实现中最具代表性的坑。当当前值标签(明亮、加粗)与相邻标记标签重叠时,需要隐藏该标记以避免视觉碰撞。直觉写法是「差值< 0.2就隐藏」,但浮点运算中2.0 - 1.8实际等于0.19999999999999996,小于 0.2,会提前一步把相隔 0.2 的相邻标记误隐藏。

TickRuler 的解法是先在步进单位下取整再比较

const hideSteps = Math.round((range * 0.08) / step); // 标记隐藏窗口 = 范围的 8%(以步进为单位) // 判断标记是否与当前值重叠:四舍五入后比较 Math.round(Math.abs(mark - current) / step) < hideSteps

这里有两层防护:其一,隐藏窗口不是硬编码的 0.2,而是范围的 8% 换算成步进数(语速场景即50 × 0.08 = 4步,覆盖 ±0.2 的标签宽度余量);其二,比较前做Math.round取整,浮点尾差在取整后被消除,2.01.8差 0.2 恰好是 4 步,不在 4 步之内,标记保持可见。对应测试用例a mark a full 0.2 away from the value stays visible despite float error专门锁定了这一行为。

拖拽预览与松手提交

每次提交都会持久化设置并触发 TTS 引擎重启 utterance,因此拖拽过程中绝不实时提交

const commit = () => { const pending = dragValueRef.current; dragValueRef.current = null; setDragValue(null); if (pending !== null && pending !== value) onSelect(pending); };
  • handleChange持续记录dragValueRef.current并更新dragValue状态——React 的 rangeonChange在拖动过程中会连续触发,这里只更新 UI 预览;
  • onPointerUp/onPointerCancel/onMouseUp/onTouchEnd四个事件统一在松手时调用commit,将pending值一次性提交;
  • commitpending !== value的判断避免了「未移动就松手」的无效提交。

键盘 500ms 防抖

按住方向键时 range 输入会连续触发多次 change,若每次 keyup 都提交会造成设置风暴。handleKeyUp采用 500ms 防抖:

const handleKeyUp = () => { if (keyboardCommitRef.current) clearTimeout(keyboardCommitRef.current); keyboardCommitRef.current = setTimeout(commit, 500); };

即按住方向键连续调整时只更新预览,松开 500ms 后才提交最终值。

无障碍与平台一致性

刻度尺的交互层是一层完全透明的原生 range 输入appearance-none opacity-0),这是有意为之:拖拽、点击、触摸、键盘方向键、屏幕阅读器朗读全部由浏览器原生控件免费提供,开发者无需自研手势识别。组件同时设置了aria-label(语速场景为 "Speed")与aria-valuetext(如1.25×),保证 VoiceOver / TalkBack 等读屏工具能播报当前值。

这与播放器内另一个滑块 [TTSScrubber](章节/进度拖动条)使用相同的「不可见原生输入 + 本地预览」模式(开发记忆中记录为[[tts-player-redesign]]的既定模式)。TickRuler根容器强制dir='ltr',确保 RTL 语言下刻度方向不被镜像。

测试验证:SpeedRuler.test.tsx

配套测试位于 SpeedRuler.test.tsx,使用 Vitest + Testing Library 覆盖五个关键行为:

测试用例验证点
渲染 0.5–3 滑块并高亮当前值min="0.5"max="3"step="0.05";当前值显示,重叠的1.0标记被invisible,其余标记可见
距当前值 0.2 的标记不因浮点误差被隐藏rate=1.82.01.5标记均可见
拖拽先预览、松手只提交一次fireEvent.change不触发onSelectpointerUp后以最终值恰好调用一次
键盘改动经防抖后提交fake timers 推进 500ms 后才以1.05调用onSelect
非网格持久值精确显示rate=0.87时渲染0.87×,不丢失小数

测试中还 mock 了useTranslation(直接返回 key),使组件可以在无 i18n 依赖下独立渲染,这也是 Readest 组件测试的通用模式。

关联演进:预览式章节拖动与缓冲时长水合

开发记忆同时记录了 SpeedRuler 之后的几条关联改动,共同构成 TTS 交互升级的整体图景:

章节拖动:1 秒步进 + 实时预览(e72ebb0c3)

章节进度条由「约章节长度的 1%」改为固定 1 秒步进,并新增实时拖动预览:TTSScrubber 以 100ms 节流(utils/throttleemitLast,并通过dragValueRef防止松手后尾随 emit)→TTSController.previewSeekTime→ 复用已构建的SectionTimeline,派发tts-highlight-mark事件并携带preview: true。预览标记跳过ttsLocation时间戳写入,并绕过useTTSControlfollowingTTSLocationRef的门控;叠加层使用独立的SEEK_PREVIEW_KEY绘制,因此播放中的单词高亮(HIGHLIGHT_KEY)重绘不会抹掉预览;seekToTime#clearAllHighlights负责清理。

已下载章节的缓冲尾部时长(9f6646866)

章节时长原本只存在于仅由 decode/fetch 填充的内存 LRU中,导致已缓存但未播放的句子仍按估算时长计(measuredFraction < 1)。修复路径为:TTSController.ensureTimeline#hydrateTimelineDurationsttsClient.getSectionDurationsCachingProvider→ 存储查询(manifest_marksJOINentries,仅取 boundaries/duration_ms,按 voice 隔离)→hydrateProvisionalDurations。记忆文档特别标注了一个隐蔽陷阱:CachingProvider包装的是按书惰性委托的BookTTSCacheStore,而非直接包装SqliteTTSCacheStore,可选存储方法必须同样转发到委托层,否则会静默 no-op。

工程实践要点总结

  1. 本地预览、松手提交:凡是「每次提交都有副作用」(持久化 + 重启引擎)的滑块,都应先渲染预览、在pointerup/防抖后再真正提交;React range 的onChange是连续触发而非提交语义。
  2. 浮点比较先在量化单位取整:任何「差值阈值」判断都应先换算到步进单位并Math.round,避免2.0 - 1.8这类尾差导致 UI 状态提前翻转。
  3. 步进选择要考虑历史配置可达性:0.05 步进并非随意,而是保证 0.75×/1.25×/1.75× 等旧预设仍精确可达。
  4. 用透明原生控件换取全平台交互appearance-none opacity-0的 range 输入让拖拽/点击/键盘/读屏全部免费,配合aria-valuetext保证无障碍。
  5. UI 字符串变更后必须跑pnpm i18n:extract:开发记忆明确指出 #5162 上线时未运行提取,导致 "Playback settings"/"Paragraph Pause" 等 key 在所有语言包中缺失——这是可复用的排查经验。

相关实现文件索引:组件封装 SpeedRuler.tsx、泛化刻度尺 TickRuler.tsx、播放器弹层接入点 TTSPlayerSheet.tsx、迷你播放器复用 TTSMiniPlayer.tsx、测试 SpeedRuler.test.tsx。

  • 桌面应用
  • 跨平台
  • 前端

【免费下载链接】readest

Readest 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),仅供参考

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

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

立即咨询