系列第 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;
}

关键点:分段不仅保存文本,还保存 startSecondendSecond

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 超时未触发时,要判定本次朗读失败
  • onCompleteonError 都要能驱动队列前进或终止

如果不做这些保护,页面就会出现很典型的错乱:

  • 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 队列怎样平滑过渡到后台媒体会话。

Logo

AtomGit 是由开放原子开源基金会联合 CSDN 等生态伙伴共同推出的新一代开源与人工智能协作平台。平台坚持“开放、中立、公益”的理念,把代码托管、模型共享、数据集托管、智能体开发体验和算力服务整合在一起,为开发者提供从开发、训练到部署的一站式体验。

更多推荐