【三国志 App 实战系列 07】Core Speech Kit 长文本 TTS 实战:HarmonyOS AI 听书分段朗读与断点续播
系列第 7 篇。本文复盘项目中 AI 听书模块的核心实现:如何把人物传记、历史事件正文转换为可暂停、可续播的 TTS 播放体验。

一、为什么不能整篇 speak
最直接的做法是:
engine.speak(longText, { requestId: 'audio-1' })
但长文本听书会遇到很多问题:
- 文本过长,TTS 调度不稳定
- 暂停后无法准确续播
- 后台切换时容易跳段
- 进度只能估算整篇,误差大
因此项目采用分段队列:先把正文按自然句切成短段,每段单独 speak()。
如果把这个问题放到真实 App 里看,会更明显。人物传记和历史事件正文往往有几千字,用户不会一次听完,而是会经历:
- 听到一半切去别的页面
- 锁屏后过一会儿回来继续
- 切换到另一位人物,再回到上一个人物
- 因为 TTS 回调异常或系统中断,需要重新恢复
所以“能朗读”只是起点,真正要解决的是长文本状态管理。这一点正是 CSDN 技术文里比较吃分的地方:不仅要给出 API,还要解释为什么这个 API 在真实场景下还不够。
二、基础常量
const AUDIO_MAX_TEXT_LENGTH: number = 3600;
const AUDIO_CHARS_PER_SECOND: number = 3.2;
const AUDIO_TTS_SEGMENT_READABLE_LENGTH: number = 80;
const AUDIO_TTS_MIN_COMPLETE_MS: number = 1200;
const AUDIO_TTS_START_TIMEOUT_MS: number = 6000;
这些值用于控制文本长度、估算时长、切段大小和看门狗超时。
三、构建音频分段
private buildAudioSegments(text: string, duration: number): AudioTextSegment[] {
const readableLength: number = this.audioReadableTextLength(text);
const segments: AudioTextSegment[] = [];
let segmentStart: number = 0;
let segmentReadable: number = 0;
let totalReadable: number = 0;
let index: number = 0;
while (index < text.length) {
const current: string = text.charAt(index);
if (this.isAudioReadableChar(current)) {
segmentReadable++;
totalReadable++;
}
const shouldCut: boolean = segmentReadable >= AUDIO_TTS_SEGMENT_READABLE_LENGTH &&
(this.isAudioSentenceBoundary(current) || segmentReadable >= AUDIO_TTS_SEGMENT_READABLE_LENGTH + 32);
if (shouldCut || index === text.length - 1) {
const segmentText: string = this.trimAudioResumeText(text.substring(segmentStart, index + 1));
const endSecond: number = Math.min(duration,
Math.max(1, Math.round(totalReadable * duration / readableLength)));
const startSecond: number = segments.length === 0 ? 0 : segments[segments.length - 1].endSecond;
segments.push(new AudioTextSegment(segmentText, startSecond, Math.max(startSecond + 1, endSecond)));
segmentStart = index + 1;
segmentReadable = 0;
}
index++;
}
return segments;
}
关键点:分段不仅保存文本,还保存 startSecond 和 endSecond。
3.1 为什么按“可读字符 + 句边界”切
很多示例会直接按固定长度 substring(),但这会带来两个问题:
- 一句话可能被拦腰截断,续播时听感很差
- 纯按字符数切段,时长估算会偏得很厉害
这个项目里把“可读字符数”和“句子边界”结合起来,目的是让每一段既不要太长,也尽量在自然停顿点结束。这样暂停后恢复时,用户听到的是一段完整语义,而不是“曹操少时机警,有...”这种半截内容。
3.2 分段对象为什么必须带时间范围
很多人会先把长文本切成 string[],后面再补进度。这个顺序在 demo 里没问题,但在实际听书页里会很快遇到麻烦:
- Slider 需要知道当前播放大概落在哪一段
- 暂停后恢复要定位到某一段内部
- 切后台后重建队列时要恢复到上次秒数
所以我更推荐一开始就把它建模成带时间范围的对象,而不是裸字符串数组。
export class AudioTextSegment {
text: string;
startSecond: number;
endSecond: number;
constructor(text: string, startSecond: number, endSecond: number) {
this.text = text;
this.startSecond = startSecond;
this.endSecond = endSecond;
}
}
这样后面的进度条、断点续播和后台恢复,都能围绕同一份结构工作。
四、创建 TTS 引擎
const engine = await textToSpeech.createEngine({
language: 'zh-CN',
person: 0,
online: 0
});
engine.setListener({
onStart: (requestId: string) => {
this.ttsSpeakInFlight = false;
this.startProgressTimer();
},
onComplete: (requestId: string) => {
this.ttsSpeakInFlight = false;
this.handleTtsSegmentComplete();
},
onError: (requestId: string) => {
this.ttsSpeakInFlight = false;
this.ttsStatus = '朗读失败';
}
});
实际项目中还要处理 onStop、过期 requestId 过滤和 fallback。
4.1 监听器不只是“回调打印”
onStart / onComplete / onError 看起来只是事件回调,但它们其实决定了播放状态是否可靠。这里至少要处理三件事:
- 新一次朗读发起后,旧回调不能再污染当前状态
onStart超时未触发时,要判定本次朗读失败onComplete和onError都要能驱动队列前进或终止
如果不做这些保护,页面就会出现很典型的错乱:
- UI 显示“正在播放”,但引擎其实已经停了
- 上一段的
onComplete把下一位人物的队列推进了 - 用户暂停后再恢复,状态被旧回调覆盖
五、Speak 参数
后台朗读要显式传递参数:
private ttsSpeakParams(requestId: string): textToSpeech.SpeakParams {
return {
requestId: requestId,
extraParams: {
volume: 1.8,
isBackStage: true
}
};
}
这里的 isBackStage: true 非常关键。后面做 AVSession 和后台播放时,如果这里没有明确声明后台朗读能力,前台表现正常,并不代表锁屏或切后台后仍然稳定。
同时建议把 requestId 设计成带业务上下文的值,而不是简单递增数字,例如:
private nextAudioRequestId(targetId: string, segmentIndex: number): string {
return `${targetId}-${segmentIndex}-${Date.now()}`;
}
这样我们在日志里一眼就能看出:当前到底是哪篇人物传记、哪一段、哪一次请求。
六、暂停与续播
暂停时记录当前秒数;续播时找到所在分段:
private audioSegmentIndexForProgress(segments: AudioTextSegment[], progress: number): number {
let index: number = 0;
while (index < segments.length) {
if (progress < segments[index].endSecond) {
return index;
}
index++;
}
return Math.max(0, segments.length - 1);
}
然后在当前分段内按字符比例截取续播文本。
6.1 续播不是“回到这一段开头”
如果暂停后总是从当前段的起始位置继续,技术上很简单,但体验会很差。更合理的做法是:
1. 先根据当前秒数找到所在分段
2. 再根据该段的起止时间估算本段已播放比例
3. 最后从该比例附近的可读字符位置截断文本
示例:
private resumeTextFromSegment(segment: AudioTextSegment, progress: number): string {
const duration = Math.max(1, segment.endSecond - segment.startSecond);
const ratio = Math.min(1, Math.max(0, (progress - segment.startSecond) / duration));
const startOffset = Math.floor(segment.text.length * ratio);
return this.trimAudioResumeText(segment.text.substring(startOffset));
}
这里的估算不可能百分之百精确,但只要落在合理区间,用户体感会明显好很多。
七、requestId 防串音与队列状态机
长文本听书最容易被忽略的一点,是TTS 回调并不天然等于“当前这次播放的回调”。只要你允许用户切换人物、暂停后恢复、重新点击播放,就可能出现旧请求晚到、却误改当前状态的情况。
我的处理方式是:每次 speak() 前都记录当前活动 requestId,所有回调先比对再执行。
private activeRequestId: string = '';
private isActiveRequest(requestId: string): boolean {
return requestId.length > 0 && requestId === this.activeRequestId;
}
private speakSegment(segment: AudioTextSegment, segmentIndex: number) {
const requestId = this.nextAudioRequestId(this.currentAudioId, segmentIndex);
this.activeRequestId = requestId;
this.ttsSpeakInFlight = true;
this.engine.speak(segment.text, this.ttsSpeakParams(requestId));
}
private handleTtsStart(requestId: string) {
if (!this.isActiveRequest(requestId)) {
return;
}
this.ttsSpeakInFlight = false;
this.ttsStatus = '播放中';
}
这样做以后,旧请求即便回调到了,也只会被丢弃,不会把新队列的状态打乱。
7.1 页面状态建议最少包含哪些字段
如果页面只存一个 isPlaying 布尔值,后面很快就不够用了。比较稳的最小状态集通常包括:
| 字段 | 作用 |
|---|---|
targetId |
当前播放的是哪篇内容 |
segments |
当前队列切出来的所有分段 |
currentSegmentIndex |
当前落在哪一段 |
activeRequestId |
用于过滤过期回调 |
ttsStatus |
UI 文案,例如播放中、已暂停、恢复中 |
ttsSpeakInFlight |
当前是否还在等待 onStart |
listenProgressSeconds |
当前秒数,用于进度条和续播 |
这些字段看起来比 demo 复杂,但它们恰好对应真实听书页最容易出问题的地方。
八、看门狗超时与失败恢复
在真实设备上,speak() 调用成功不等于一定会收到 onStart。如果一直等回调,页面就会卡在“准备播放”状态。
因此项目里需要一个简单看门狗:超过一定时间还没 onStart,就把这次请求视为失败,并允许用户重试或自动跳过。
private startSpeakWatchdog(requestId: string) {
clearTimeout(this.ttsStartTimer);
this.ttsStartTimer = setTimeout(() => {
if (!this.isActiveRequest(requestId) || !this.ttsSpeakInFlight) {
return;
}
this.ttsSpeakInFlight = false;
this.ttsStatus = '朗读启动超时';
}, AUDIO_TTS_START_TIMEOUT_MS);
}
8.1 失败恢复要避免两个极端
- 不能无限自动重试,否则容易进入死循环
- 也不能一失败就彻底终止,否则长文本体验很脆弱
我更建议采用“有限重试 + 用户可见状态”的策略:
private ttsRetryCount: number = 0;
private static readonly AUDIO_TTS_MAX_RETRY = 1;
private handleTtsError(requestId: string) {
if (!this.isActiveRequest(requestId)) {
return;
}
this.ttsSpeakInFlight = false;
if (this.ttsRetryCount < AudioPlayer.AUDIO_TTS_MAX_RETRY) {
this.ttsRetryCount++;
this.replayCurrentSegment();
return;
}
this.ttsStatus = '朗读失败,请重试';
}
这个策略的好处是:偶发失败能自愈,连续失败又不会把用户困在一个隐藏循环里。
九、断点续播如何落盘
如果听书进度只保存在页面内存里,用户切后台、杀进程、重启 App 后就全丢了。对于人物传记这种长文本,这个体验是不合格的。

建议把断点记录抽成一个轻量结构,按 targetId 持久化:
export interface AudioProgressRecord {
targetId: string;
listenedSeconds: number;
updatedAt: number;
}
保存时不要每一秒都 flush(),可以在以下时机写盘:
- 用户主动暂停
- 当前段完成
- 页面
aboutToDisappear - App 进入后台
async function saveAudioProgress(record: AudioProgressRecord) {
const store = await preferences.getPreferences(getContext(), 'audio_progress');
await store.put(record.targetId, JSON.stringify(record));
await store.flush();
}
9.1 页面恢复顺序
恢复时的顺序也很重要:
1. 先读本地进度
2. 再重建分段数组
3. 根据秒数定位当前段
4. 最后决定是否自动续播
如果顺序颠倒,例如先自动 speak() 再计算分段,进度和队列就会错位。
十、调试命令与日志观察
文章只给代码不够,最好还要给读者一条能落地的排查路径。对 HarmonyOS 听书模块来说,我最常用的是下面几条命令:
hdc list targets
hdc shell hilog | Select-String -Pattern "Audio|TTS|Speech|AVSession"
hdc shell aa force-stop com.example.recordofthreekingdoms
hdc shell aa start -a EntryAbility -b com.example.recordofthreekingdoms
如果要检查暂停续播是否正确,可以在关键位置打印业务日志:
hilog.info(0x0000, 'AudioPlayer', 'speak segment=%{public}d request=%{public}s progress=%{public}d',
segmentIndex, requestId, this.listenProgressSeconds);
这样复盘问题时,我们可以回答几个关键问题:
- 当前发起的是哪一段
- 回调返回的是不是当前活动
requestId - 页面显示的进度和实际记录的进度是否一致
十一、常见问题复盘
把 07 这篇文章只写成“如何调用 TTS API”是不够的,因为长文本听书的难点主要出在状态和异常,而不是 API 本身。下面这张表是我在项目里最常遇到的几类问题:
| 问题 | 表现 | 处理方式 |
|---|---|---|
整篇直接 speak() |
长文本卡顿,暂停后难续播 | 先切段,再按段维护队列 |
| 旧回调污染新状态 | 切换人物后 UI 状态错乱 | 用 activeRequestId 过滤过期回调 |
onStart 长时间不触发 |
页面卡在“准备播放” | 增加启动超时看门狗 |
| 进度只存在内存里 | 重启 App 后丢失断点 | 用 Preferences 保存 listenedSeconds |
| 续播总从段首开始 | 用户反复听到同一段 | 根据秒数和字符比例截断续播文本 |
这类复盘表对读者和质量分都很有帮助,因为它说明文章不是停留在“写法展示”,而是覆盖了工程里的实际坑位。
十二、工程实现与验收清单
长文本听书的关键是“队列化”。如果只调用一次 speak(),短文本可能没问题,但人物传记、事件文章这类长文本很容易遇到卡顿、无法恢复、进度不可控的问题。
interface SpeechSegment {
id: string;
index: number;
text: string;
startOffset: number;
endOffset: number;
}
interface SpeechQueueState {
targetId: string;
segments: SpeechSegment[];
currentIndex: number;
status: 'idle' | 'playing' | 'paused' | 'completed';
}
分段不要只按固定长度切,否则可能把一句话切断。可以先按段落和标点切,再控制最大长度。
function splitText(text: string, maxLength: number): string[] {
const sentences = text.split(/(?<=[。!?;])/);
const result: string[] = [];
let current = '';
sentences.forEach((sentence) => {
if ((current + sentence).length > maxLength && current.length > 0) {
result.push(current);
current = sentence;
} else {
current += sentence;
}
});
if (current.length > 0) {
result.push(current);
}
return result;
}
| 场景 | 期望结果 |
|---|---|
| 长文本开始播放 | 能从第一段开始朗读 |
| 暂停后继续 | 从当前段继续,而不是回到开头 |
| 切换人物 | 上一个队列停止,新队列创建 |
| App 重启 | 能恢复最近播放记录 |
| 异常回调 | 不触发重复播放 |
12.1 发布前我会额外检查什么
除了上面的功能表,我还会在真机或模拟器上额外过一遍下面的自检:
- 长按返回桌面再回到 App,听书状态是否还合理
- 切换到另一位人物后再回来,旧队列是否彻底停止
- 进度条拖动后,新的续播文本是否和预期接近
- 失败重试时,页面文案是否能明确提示用户当前状态
- 截图、代码块、问题复盘表是否足够支撑一篇完整技术文章
十三、小结
长文本 TTS 的核心不是“能不能出声”,而是能不能稳定地暂停、续播、切段、后台恢复。本文把实现重点拆成了五部分:分段队列、时间估算、requestId 防串音、看门狗超时、断点续播落盘。
如果你也在做 HarmonyOS / ArkTS 的听书模块,建议不要把它当成一个单纯的 speak() 调用问题,而要把它当成一个小型播放状态机来设计。下一篇会继续讲后台播放与 AVSession,看看前台 TTS 队列怎样平滑过渡到后台媒体会话。
AtomGit 是由开放原子开源基金会联合 CSDN 等生态伙伴共同推出的新一代开源与人工智能协作平台。平台坚持“开放、中立、公益”的理念,把代码托管、模型共享、数据集托管、智能体开发体验和算力服务整合在一起,为开发者提供从开发、训练到部署的一站式体验。
更多推荐

所有评论(0)