鸿蒙 TTS 播放时序实战:让听书不丢字、不跳段,我用“完成信号 + EMA 校准“对齐朗读进度
这是我开发的第三个鸿蒙 App,已经上架华为应用市场。前两个是git仓库查看工具和批量加水印工具,这一个,是把文档变成声音的听书软件「BookVoice」。三个 App 做完,我最想说的一句话是:真正让你脱层皮的不是语言,是"系统思维"——而这套思维,第三个 App 时已经在复用了。
这个 App 是干嘛的? 名字叫BookVoice,一句话:把 TXT / MD / PDF / DOCX / EPUB 等文档变成声音,用耳朵听书。目标用户很具体——通勤路上想把积压的电子书读完的上班族、做家务时想"解放双眼"继续学习的人、出差途中想听长文但不想把私人文档交给在线工具的阅读爱好者。
听起来很常规?其实听书 App 有个天然的技术难题:朗读不能丢字、不能跳段。你写前端、写工具类 App 时,从没想过"逐句读一段文本"能成为核心难点——直到你真正去实现。
先看它有哪些功能,后面讲技术点,你就知道每个功能"硬"在哪:
- 📄 多格式即导即读:TXT / MD / PDF / DOCX / EPUB 一键导入,PDF 自动逐页提取文本并智能分段,TXT 保持原文分段并合并被拆散的句子
- ✨ 朗读跟随高亮:当前朗读段落高亮 + 当前朗读字逐字金色跟随(这个后面展开讲)
- ⏯️ 专业播放控制:精确到句的快进快退、0.5x~2.0x 倍速、章节目录一键跳转
- ⏰ 睡眠计时:30/60/90/120 分钟定时停止,到点自动停播
- 🔄 后台持续朗读:退出页面、锁屏、切 App 不中断,系统控制中心播控
- 📊 听书数据统计:今日 / 累计时长 + 聆听目标进度环 + 已听 / 未听书单;聆听时长解锁 10 级勋章
- ☁️ 云端同步:华为账号登录,听书进度云端同步,换机无缝接续
- 🔒 隐私保障:文档全程本地处理、不上传、不存储
下面挑三个花心思最多的技术点展开——它们正好是前两个 App 都没碰到过的领域:文本解析、语音播放时序,还有逐字位置。
先看选型差异:一张表说清楚
| 维度 | 传统 Web / Android | HarmonyOS(ArkTS + ArkUI + Stage) |
|---|---|---|
| 语言 | JS / Kotlin 等 | ArkTS = TypeScript 严格子集,no-any / no-untyped-obj-literals 一票禁止 |
| UI | DOM / XML 布局 | ArkUI 声明式,@Component + @State 状态驱动 |
| 生命周期 | Activity / 页面栈 | Stage 模型:UIAbility + AbilityStage,导航用 NavPathStack |
| 语音 | 第三方 SDK | @kit.CoreSpeechKit 文本转语音,callback 为主,需手工 Promise 包装 |
| 云 | 自建后端 | AGC 端云一体化:CloudDB / 匿名登录一条龙 |
ArkTS 严格模式已经是老朋友了——前两个 App 就领教过。但真正让我意外的是语音合成 API 的"回调地狱":downloadVoice 这种接口 SDK 只给 callback 重载,没有 Promise,得自己包装;进度事件载荷还是个字符串,得剥掉非数字字符解析成 0~100。
三个核心技术点
① 文本解析:PDF 不是"读出来"就完了
PDF 逐页提取文本后,最烦人的是硬换行拆词——"坐在"两个字可能被拆成两行,直接朗读就会断句奇怪。自研 TextParser 干三件事:按句末标点(。!?)识别句子边界、识别章节标题(「第一章」「Chapter 1」甚至裸阿拉伯数字「1」)、按空行合并自然段落。这样解析出的文本才能按"段落"为单位朗读,而不是一个字符一个字符地念。
// 核心:把被硬换行拆散的文本按句末标点重新合并成自然段落
function parseParagraphs(lines: string[]): string[] {
const paras: string[] = [];
let cur = '';
for (const t of lines) {
if (isChapterTitle(t)) { // 章节标题独立成段
if (cur) { paras.push(cur); cur = ''; }
paras.push(t);
} else if (endsSentence(t)) { // 句末标点收尾 → 段落结束
cur += t; paras.push(cur); cur = '';
} else {
cur += t; // 续行合并
}
}
if (cur) { paras.push(cur); }
return paras;
}
踩过的坑:PDF 段落编号(独立成行的「1」「2」)会被并进下一段成为首行首字。最后给 isChapterTitle 加了"整行仅 1~3 位数字即视为标题"的规则,与既有章节识别对称,问题根治。
② TTS 播放时序:让朗读不丢字、不跳段
听书 App 的命门是换段时机。最初按每秒 tick 反查段落推进,结果段末 1~2 字被掐断、段首 1~3 字被跳过——用户听着听着就少几个字。根因是估算播放时长与真实 TTS 播报有偏差。
改法是让真实 TTS 播报完成信号驱动换段:onComplete 置完成标记,配合估算结束点 next >= cap 双重校验,再加超时兜底;同时用 EMA 指数加权校准播报速度,让进度条与语音始终同步。模拟器上 TTS 每段真实触发 onComplete 但耗时只有 0.6~1.3 秒(远低于估算),一度被误判为"播完"连续跳段——加了个可信区间过滤(realSec ∈ [0.5×, 2.5×]×估算),失真耗时不参与校准。
// 核心:换段由「估算结束点已到」+「完成信号或超时兜底」共同驱动
if (next >= cap && (completionArrived || stallTicks >= stallLimit)) {
advanceParagraph();
completionArrived = false;
stallTicks = 0;
}
这段工程解决的是听书最基本的问题:不丢字、不跳段。它不花哨,但很基础——听书嘛,听着顺耳最重要。
③ 朗读跟随高亮:逐字位置,是「算」出来的
这个功能源自一个很朴素的需求:朗读到哪,字就亮到哪。用户看着正文,声音走到哪个字,那个字上就有一个金色小框跟着移动。竞品 iOS 应用有,鸿蒙上没有现成方案——因为两堵墙摆在那:
- Text 组件没有逐字位置 API,拿不到「第 N 个字符在屏幕上的 (x, y)」;
- TTS 回调里也没有逐字进度,不知道现在读到第几个字。
只能自己算。位置用 measureText 逐字测宽,再按和正文完全一致的断行规则(中文逐字断、英文按空格断词、首行缩进一致)做贪心换行,模拟出每个字的坐标:
// 核心:把"第 N 个字符"翻译成屏幕坐标(规则与正文渲染完全一致)
function locateChar(text: string, n: number): { x: number; y: number } {
let line = 0, x = 0;
for (let i = 0; i < n; i++) {
const w = measureText(text[i]); // 逐字测宽
if (x + w > lineWidth) { line++; x = 0; } // 贪心换行
x += w;
}
return { x, y: line * lineHeight };
}
进度同样没有回调——固定语速估算会漂移,于是用真实播报耗时反推实测语速(EMA 校准 + 前导系数补偿段内停顿),让金框既不落后也不超前地跟住当前朗读的字。这大概是这个 App 里最「教科书上没有、只能自己造」的一段工程:没有逐字 API 的鸿蒙上,逐字高亮就是这么硬算出来的。
做得好的 & 还能优化的
做得好的:文本解析 + TTS 时序双管齐下,朗读稳定不丢字;逐字跟随高亮让"看字听书"有了跟读感;AVSession 后台播控 + 书架迷你条让"边做家务边听"体验完整;AGC 端云一体化让换机接续听书进度几乎零后端成本。
还能优化的:PDF 复杂版式(多栏、表格、页眉页脚)的解析准确率还有提升空间;大文档首次解析耗时较长,希望进一步优化分阶段解析策略;听书时长统计目前只做了按日结算,周 / 月 / 年维度统计在 TODO 里。
给同行的话
一个技术路线的价值,在第二个、第三个 App 时才真正显现。 git仓库查看、加水印、听书,三个完全不同的 App,但共用同一套 ArkTS 方法论:严格模式逼出的类型意识、Stage 模型的生命周期管理、AGC 端云一体化的"免后端"打法。每做一个,都在往这套方法论里加新的一块。
如果你也在手机上读了大量电子书、长文,想试试"用耳朵读完",华为应用市场搜「BookVoice」就能用。
转载请注明文章来源:
更多推荐




所有评论(0)