HarmonyOS应用《民族图鉴》开发第34篇:TTSEngineService——语音引擎封装与降级策略

在这里插入图片描述

一、TTS 技术原理深度解析

在开始封装 TTS 服务之前,我们有必要先了解一下 TTS(Text-to-Speech,文本转语音)技术的底层原理。理解了原理,才能更好地设计和使用 TTS 服务。

1.1 TTS 技术的三个核心阶段

现代 TTS 系统通常由三个核心阶段组成:

文本输入 → 文本分析 → 韵律生成 → 语音合成 → 音频输出
阶段一:文本分析(Text Analysis)

文本分析是 TTS 的第一步,负责把 raw text 转换成语言学上的结构化表示。

主要任务:

任务说明示例
文本归一化把数字、符号、缩写转换成完整文字“123” → “一百二十三”
分词把句子切分成词语(中文尤其重要)“民族图鉴” → [“民族”, “图鉴”]
词性标注标注每个词的词性(名词/动词/形容词)“汉族” → 名词
韵律标注标注重音、停顿、语调模式逗号处短停,句号处长停
多音字处理根据上下文确定多音字的正确发音“快乐” vs “音乐”

为什么民族图鉴需要关注文本分析?

因为 56 个民族的名称中有很多生僻字和多音字,比如:

  • “仡佬族"的"仡”(gē)
  • “畲族"的"畲”(shē)
  • “僳僳族"的"僳”(sù)
  • “达斡尔族"的"斡”(wò)

如果 TTS 引擎读错了民族名称,用户体验会大打折扣。好的 TTS 引擎应该支持自定义发音词典,我们可以把 56 个民族的正确发音提前配置好。

阶段二:韵律生成(Prosody Generation)

韵律是指语音的"旋律"——语速、音调、节奏、重音等。韵律决定了合成语音听起来自不自然。

韵律的关键要素:

要素说明调节范围
语速(Speed)每秒说多少个字0.5x ~ 2.0x
音调(Pitch)声音的高低频率0.5x ~ 2.0x
音量(Volume)声音的大小0 ~ 15(或 0~100)
重音(Stress)哪些词需要强调自动检测 + 手动标注
停顿(Pause)词间/句间的停顿时长自动预测

民族图鉴中的韵律设计:

在民族介绍的朗读中,我们需要考虑:

  • 民族名称适当放慢、加重,让用户听清楚
  • 节日、习俗等关键词适当强调
  • 段落之间有足够的停顿,给用户消化时间
  • 整体语速适中,偏慢一点(因为是知识类内容)
阶段三:语音合成(Speech Synthesis)

语音合成是最后一步,把韵律参数转换成实际的音频波形。

主流合成技术对比:

技术说明音质自然度资源占用
拼接合成(Unit Selection)从语音库中挑选单元拼接一般大(语音库大)
参数合成(Parametric)HMM 等统计模型生成参数一般一般
神经网络合成(Neural TTS)Tacotron、WaveNet 等深度模型很高很高大(算力要求高)
端到端合成(End-to-End)文本直接生成音频最高最自然最大

HarmonyOS 的 Core Speech Kit 采用的是云端神经网络 TTS(在线模式)+ 本地参数化 TTS(离线模式)的混合方案。

1.2 TTS 技术的评价指标

怎么衡量一个 TTS 系统好不好?主要看三个维度:

1. 自然度(Naturalness)

  • 听起来像不像真人说话?
  • 有没有机器感、金属感?
  • 语调是否自然流畅?

常用 MOS 分(Mean Opinion Score)衡量,1-5 分:

  • 5分:完全像真人
  • 4分:比较自然,偶尔有机器感
  • 3分:能听懂,但明显是机器
  • 2分:听起来很吃力
  • 1分:完全听不懂

2. 清晰度(Intelligibility)

  • 每个字都能听清楚吗?
  • 多音字、生僻字读得对不对?
  • 快速朗读时还能听清吗?

3. 表现力(Expressiveness)

  • 能不能表达不同的情感(高兴、悲伤、严肃)?
  • 能不能模仿不同的说话风格(新闻播报、故事讲述、对话)?
  • 能不能控制重音、停顿等细节?

🎯 民族图鉴的 TTS 选型考量:对于文化科普类应用,清晰度 > 自然度 > 表现力。首先要保证每个民族名称、专有名词都读对、读清楚,其次才是听起来自然不自然。情感表现力反而是最不重要的——毕竟是知识类内容,平实的朗读风格就够了。

二、为什么需要 TTS 服务

TTS(Text-to-Speech,文本转语音)是民族图鉴应用的一个重要辅助功能

  • 无障碍支持:视力不好的用户可以"听"民族介绍
  • 学习辅助:听民族名称、节日名称的标准发音
  • 沉浸体验:浏览文化内容时配上朗读,体验更好
  • 多语言:中文/英文切换,自动切换对应语音

但是,直接调用系统 TTS 能力会有很多问题:

问题表现
初始化复杂创建引擎、设置监听器、配置参数… 一大堆样板代码
状态混乱播放中、空闲、错误… 各个页面自己维护状态
错误处理缺失引擎初始化失败就崩了,页面直接白屏
无法测试模拟器上没有 TTS 能力,开发调试全靠真机
代码重复每个页面都写一遍创建/播放/停止的逻辑
长文本支持差原生 API 对超长文本支持不好,需要自己分段
多语言切换麻烦切换语言要重建引擎,逻辑复杂

解决方案:封装 TTSEngineService,把 TTS 的复杂性藏在服务内部。

三、系统能力:Core Speech Kit 深度解析

HarmonyOS 提供了系统级的语音能力,位于 @kit.CoreSpeechKit 包中。这是一个功能强大的语音服务套件,包含了 TTS、ASR(语音识别)、声纹识别等多种能力。

import { textToSpeech } from '@kit.CoreSpeechKit';

3.1 Core Speech Kit 整体架构

Core Speech Kit 采用"引擎框架 + 多引擎适配"的架构:

┌─────────────────────────────────────────┐
│          应用层 (Your App)              │
├─────────────────────────────────────────┤
│       Core Speech Kit 框架层            │
│  ┌──────────┬──────────┬──────────┐    │
│  │  TTS API │ ASR API  │  其他... │    │
│  └──────────┴──────────┴──────────┘    │
├─────────────────────────────────────────┤
│         引擎适配层 (Engine Adapter)     │
├─────────────────────────────────────────┤
│  ┌──────────┬──────────┬──────────┐    │
│  │ 华为TTS  │ 第三方  │ 离线引擎  │    │
│  │ 云端引擎 │ 引擎    │          │    │
│  └──────────┴──────────┴──────────┘    │
└─────────────────────────────────────────┘

架构优势:

  • 统一 API:不管底层用什么引擎,上层调用方式一致
  • 灵活切换:可以根据网络、设备能力动态选择引擎
  • 可扩展:新引擎只需要实现适配层,不需要改上层代码
  • 多音色支持:不同引擎提供不同的音色选择

3.2 核心概念详解

概念说明对应民族图鉴的使用场景
TextToSpeechEngineTTS 引擎实例,所有操作都通过它全局单例,整个 app 共用一个
CreateEngineParams创建引擎的参数(语言、音色、在线/离线等)启动时根据当前语言创建
SpeakParams朗读参数(请求ID、额外参数等)每次 speak 时指定语速音量
SpeakListener状态监听器(开始/完成/停止/错误)页面监听状态更新 UI

3.3 在线引擎 vs 离线引擎

Core Speech Kit 支持两种引擎模式:

对比维度在线引擎(online: 1)离线引擎(online: 0)
技术方案云端神经网络 TTS本地参数化 TTS
音质高,接近真人一般,明显机器感
自然度很好,语调自然一般,比较平
响应速度有网络延迟(200-500ms)快,几乎即时响应
流量消耗有,每次朗读都要请求
网络依赖必须联网不需要网络
多语言支持多,中英日韩等都有少,通常只有中英
音色选择多,多种音色可选少,通常只有 1-2 种
包体积影响无,不用打包语音数据大,语音库几百 MB

民族图鉴的选择策略:

// 优先使用在线引擎(音质好)
// 如果网络不可用或初始化失败,再考虑降级
const initParams: textToSpeech.CreateEngineParams = {
  language: 'zh-CN',
  person: 0,
  online: 1,  // 优先在线
  extraParams: {
    'style': 'interaction-broadcast',
    'locate': 'CN',
    'name': 'EthnicTTS'
  }
};

💡 最佳实践:对于知识类、内容类应用,优先用在线引擎保证音质。同时要有降级方案,网络不好时也能用。对于工具类、指令类应用,离线引擎可能更合适(响应快、不需要网络)。

3.4 创建引擎详解

const initParams: textToSpeech.CreateEngineParams = {
  language: 'zh-CN',   // 语言
  person: 0,            // 音色:0=女(聆小珊), 1=男(凌飞哲), 2=英文(Laura)
  online: 1,            // 1=在线引擎, 0=离线引擎
  extraParams: {
    'style': 'interaction-broadcast',
    'locate': 'CN',
    'name': 'EthnicTTS'
  }
};

textToSpeech.createEngine(initParams, (err, engine) => {
  if (!err) {
    // 引擎创建成功
  }
});

参数详解:

参数类型说明可选值
languagestring语言代码zh-CN, en-US, ja-JP 等
personnumber音色编号0=女声,1=男声,具体取决于引擎
onlinenumber是否在线引擎1=在线,0=离线
extraParamsObject额外参数因引擎而异

extraParams 常用配置:

key说明示例值
style朗读风格interaction-broadcast(交互播报)
locate区域定位CN(中国大陆)
name引擎实例名称EthnicTTS(用于日志追踪)

注意createEngine回调式 API,不是 Promise。所以我们需要把它包装成 Promise,方便 async/await 使用。

四、单例与初始化

4.1 单例模式深入

和 StorageService 一样,TTS 服务也是单例。让我们深入理解一下为什么 TTS 必须用单例。

export class TTSEngineService {
  private static instance: TTSEngineService;
  private ttsEngine: textToSpeech.TextToSpeechEngine | null = null;
  private currentState: TTSState = TTSState.IDLE;
  private isEngineReady: boolean = false;
  private isDemoMode: boolean = false;
  private currentCallback: TTSCallback | null = null;

  private constructor() {}

  public static getInstance(): TTSEngineService {
    if (!TTSEngineService.instance) {
      TTSEngineService.instance = new TTSEngineService();
    }
    return TTSEngineService.instance;
  }
}

为什么 TTS 必须单例?

  1. 引擎资源有限:创建多个 TTS 引擎实例浪费系统资源

    • 每个引擎实例都要占用内存
    • 在线引擎还要维护网络连接
    • 系统可能限制同时存在的引擎数量
  2. 状态统一管理:全局只有一个播放状态,不会"多个声音一起响"

    • 详情页在朗读,用户点了列表页的另一个朗读按钮怎么办?
    • 如果是两个实例,两个声音会叠在一起
    • 单例模式下,新请求会被防重入逻辑挡住
  3. 配置统一:语速、音量、音色等设置全局一致

    • 用户在设置页改了语速,所有页面都生效
    • 不用每个页面都同步配置
  4. 音频焦点统一管理:后面加音乐播放时,统一处理音频焦点

    • TTS 和音乐不能同时响
    • 单例模式下,服务层可以统一协调

⚠️ 注意:ArkTS 中的单例和纯 JS/TS 的单例有一点不同——ArkTS 对静态成员的支持有一些限制,需要确保单例实现符合 ArkTS 的语法规范。上面的写法是经过验证的,可以放心使用。

4.2 初始化时机与流程

TTS 引擎什么时候初始化?这是一个重要的架构决策。

可选方案对比:

方案说明优点缺点
应用启动时初始化EntryAbility.onCreate 中 init使用时已经就绪,响应快增加启动时间,可能用不上
首次使用时初始化第一次调用 speak 时才 init不影响启动速度首次使用有延迟
预加载(空闲时)启动后空闲时间后台初始化兼顾启动速度和使用体验实现复杂

民族图鉴的选择:应用启动时初始化

理由:

  • TTS 是重要功能,用户很可能会用到
  • 启动时初始化,进入详情页就能直接用,体验更好
  • 初始化是异步的,不会阻塞主线程太久
// EntryAbility.ets 中
export default class EntryAbility extends UIAbility {
  async onCreate(want: Want, launchParam: AbilityConstant.LaunchParam): Promise<void> {
    // 其他初始化...
    
    // 异步初始化 TTS(不 await,不阻塞启动)
    TTSEngineService.getInstance().init();
  }
}

💡 技巧:初始化不 await,让它在后台异步执行。这样既不阻塞启动,又能在用户用到之前大概率完成初始化。如果用户点朗读时还没初始化完,可以加个 loading 或者直接走降级模式。

4.3 初始化方法详解

public async init(): Promise<void> {
  try {
    await this.createEngine();
    this.isEngineReady = true;
    hilog.info(DOMAIN, 'TTSService', 'TTS Engine initialized successfully');
  } catch (e) {
    hilog.warn(DOMAIN, 'TTSService',
      `TTS init failed, fallback to demo mode: ${JSON.stringify(e)}`);
    this.isDemoMode = true;
    this.isEngineReady = true;  // 标记为就绪(降级模式)
  }
}

关键点:

  1. 不抛异常:初始化失败不会抛出,应用不会因为 TTS 用不了就崩了
  2. 自动降级:失败了进入 demo mode(模拟模式),功能还能"假装"工作
  3. 都标记 ready:不管成功失败,isEngineReady 都设为 true,上层不用区分

🎯 设计思想优雅降级(Graceful Degradation)——核心功能(民族图鉴浏览)不受影响,TTS 只是锦上添花的增强功能。用不了就用不了,不能让它拖垮整个应用。

4.4 回调转 Promise

原生 createEngine 是回调风格,我们包装成 Promise:

private createEngine(): Promise<void> {
  return new Promise((resolve, reject) => {
    const initParams: textToSpeech.CreateEngineParams = {
      language: this.config.language,
      person: 0,
      online: 1,
      extraParams: {
        'style': 'interaction-broadcast',
        'locate': 'CN',
        'name': 'EthnicTTS'
      }
    };

    textToSpeech.createEngine(initParams, (err, engine) => {
      if (!err) {
        this.ttsEngine = engine;
        this.setupListener();
        resolve();
      } else {
        reject(new Error(`createEngine failed: code=${err.code}`));
      }
    });
  });
}

这是一个非常常见的模式——把回调式 API 包装成 Promise,这样上层就可以用 async/await 写同步风格的代码了。

为什么要转 Promise?

  1. 代码更简洁:async/await 比回调嵌套好读太多了
  2. 错误处理更统一:try-catch 统一捕获,不用每个回调都判断 err
  3. 便于组合:Promise 可以用 Promise.all、Promise.race 等组合
  4. 符合现代 JS/TS 开发习惯:现在大家都习惯用 async/await 了

📝 ArkTS 注意事项:ArkTS 对 Promise 的支持是比较完善的,async/await 都可以正常使用。但是要注意,不要在 UI 线程做太重的异步操作,避免卡顿。

五、状态管理与状态机

5.1 状态枚举

export enum TTSState {
  IDLE = 'idle',        // 空闲
  SPEAKING = 'speaking', // 播放中
  ERROR = 'error'       // 错误
}

三种状态,构成一个简单的状态机:

        speak()            stop()
  IDLE ─────────→ SPEAKING ─────────→ IDLE
    ↑                │
    │  onComplete    │ onError
    └────────────────┴────────→ ERROR
                          ↑
                          │
                    recover() / reset()
                          │
                         IDLE

更完整的状态机(进阶版):

如果要支持长文本、暂停、缓冲等功能,状态机会更复杂:

           ┌──────────┐
           │   IDLE   │ (空闲)
           └────┬─────┘
                │ speak()
                ▼
           ┌──────────┐
     ┌────→│ LOADING  │ (加载中)
     │     └────┬─────┘
     │          │ onStart
     │          ▼
     │     ┌──────────┐
     │     │ SPEAKING │ (播放中)
     │     └────┬─────┘
     │          │ pause()
     │          ▼
     │     ┌──────────┐
     │     │ PAUSED   │ (已暂停)
     │     └────┬─────┘
     │          │ resume()
     │          └───────→ SPEAKING
     │
     │  stop() / onComplete / onError
     └─────────────────────→ IDLE / ERROR

民族图鉴目前用简化版的三状态机就够了,因为我们的场景比较简单:

  • 不需要暂停/继续(用户停了就重新读)
  • 文本不算太长,加载时间可以忽略
  • 出错了就回到空闲状态,不用单独的恢复流程

5.2 为什么需要自己管理状态

原生引擎也有状态,但我们为什么还要自己维护 currentState

  1. 降级模式需要:demo mode 下没有真实引擎,状态要自己管
  2. 访问同步getState() 是同步方法,不用等引擎回调
  3. 防重入控制:在 speak() 开头直接判断状态,不用问引擎
  4. 统一抽象:真实模式和降级模式对外表现一致
  5. 状态可预测:自己维护状态机,状态流转是确定的,不会出现"引擎说在播但实际没声音"这种尴尬情况
  6. 便于测试:单元测试时可以直接设置状态,不用依赖真实引擎

5.3 防重入

public speak(text: string, callback?: TTSCallback): void {
  // 空文本直接返回
  if (!text || text.trim().length === 0) {
    return;
  }

  // 防重入:正在播放时忽略新请求
  if (this.isSpeaking()) {
    hilog.warn(DOMAIN, 'TTSService', 'speak called while already speaking, ignored');
    return;
  }

  // ... 真正的播放逻辑
}

防重入的重要性:用户连点两下朗读按钮,总不能两个声音叠在一起吧?在服务层就把这件事挡住,页面层不用操心。

防重入的常见策略:

策略说明适用场景
忽略新请求正在执行就忽略新的TTS 朗读、支付等"同时只能一个"的操作
排队执行新请求加入队列,等前面完成再执行消息发送、数据上报
取消旧请求新请求来了就取消旧的,执行新的搜索联想、数据刷新
合并请求把多个请求合并成一个批量上传、批量查询

TTS 用"忽略新请求"是最合适的,因为:

  • 朗读是"独占式"操作,两个一起响体验很差
  • 用户点了新的朗读,应该先手动 stop 再开始新的
  • 排队的话,用户可能已经离开页面了还在朗读,体验更差

💡 经验之谈:任何"不能同时执行多个"的操作,都要在服务层做防重入。不要相信页面会老老实实调用——用户可能连点,代码可能有 bug,多一层防护总是好的。

六、事件监听与回调

6.1 监听器设置

private setupListener(): void {
  if (!this.ttsEngine) return;

  const listener: textToSpeech.SpeakListener = {
    onStart: (requestId, response) => {
      this.currentState = TTSState.SPEAKING;
      this.currentCallback?.onStart?.();
    },

    onComplete: (requestId, response) => {
      this.currentState = TTSState.IDLE;
      this.currentCallback?.onComplete?.();
    },

    onStop: (requestId, response) => {
      this.currentState = TTSState.IDLE;
      this.currentCallback?.onStop?.();
    },

    onError: (requestId, errorCode, errorMessage) => {
      this.currentState = TTSState.ERROR;
      this.currentCallback?.onError?.(errorCode, errorMessage);
    }
  };

  this.ttsEngine.setListener(listener);
}

监听器的四个回调时机:

回调触发时机对应状态变化
onStart开始播放时IDLE → SPEAKING
onComplete正常播放完成时SPEAKING → IDLE
onStop被主动停止时SPEAKING → IDLE
onError播放出错时SPEAKING → ERROR

为什么 onComplete 和 onStop 要分开?

因为两者的语义不一样:

  • onComplete:读完了,是"正常结束"
  • onStop:被打断了,是"异常结束"

页面可以根据不同的结束原因做不同的处理。比如:

  • onComplete:自动播放下一段(长文本场景)
  • onStop:什么都不做(用户主动停的)

6.2 回调设计

export interface TTSCallback {
  onStart?: () => void;
  onComplete?: () => void;
  onStop?: () => void;
  onError?: (code: number, message: string) => void;
}

为什么用回调而不是 Promise?

因为 TTS 播放是一个持续过程,有多个时间点:

  • 开始播放(onStart)
  • 播放完成(onComplete)
  • 被停止(onStop)
  • 出错了(onError)

Promise 只能表示"成功/失败"一次,没法表示"开始了… 然后完成了"这样的过程。

使用方式:

ttsService.speak('你好,世界', {
  onStart: () => {
    console.log('开始朗读了');
    this.isPlaying = true;
  },
  onComplete: () => {
    console.log('朗读结束');
    this.isPlaying = false;
  },
  onError: (code, msg) => {
    console.error('朗读出错:', code, msg);
    this.isPlaying = false;
  }
});

6.3 观察者模式的思考

现在的回调设计是"单次回调"——每次 speak 传一个 callback,只对这一次朗读有效。这对于简单场景够用了,但如果有多个地方都想监听 TTS 状态,就不够用了。

更灵活的方案:观察者模式(监听者模式)

// 多个观察者可以同时监听
export class TTSEngineService {
  private listeners: Set<TTSListener> = new Set();

  addListener(listener: TTSListener): void {
    this.listeners.add(listener);
  }

  removeListener(listener: TTSListener): void {
    this.listeners.delete(listener);
  }

  private notifyStart(): void {
    this.listeners.forEach(listener => listener.onStart?.());
  }
}

两种方案对比:

方案优点缺点适用场景
单次回调简单、直接、用完即走只能有一个监听者简单页面,只需要更新按钮状态
观察者模式支持多监听者、解耦实现复杂、需要管理订阅复杂应用,多个页面/组件都要响应状态变化

民族图鉴目前用单次回调就够了,因为只有详情页的朗读按钮需要监听状态。如果以后要加"全局播放控制栏"等功能,可以考虑升级成观察者模式。

6.4 可选链的妙用

this.currentCallback?.onStart?.();

这一行有两个可选链 ?.

  • currentCallback?.onStart:如果 currentCallback 是 null/undefined,什么都不做
  • ?.():如果 onStart 是 undefined(用户没传),什么都不做

好处是——用户传不传回调、传哪几个回调,都不用写 if 判断,直接调用就行。

七、配置管理:语速、音调、音量

7.1 配置接口详解

export interface TTSConfig {
  language: string;   // 语言: 'zh-CN', 'en-US'
  speed: number;      // 语速: 0.5~2.0, 默认 1.0
  pitch: number;      // 音调: 0.5~2.0, 默认 1.0
  volume: number;     // 音量: 0~15, 默认 10
}

三个可调参数的详细说明:

参数范围默认值说明民族图鉴建议
speed(语速)0.5 ~ 2.01.0每秒读多少字,值越大越快知识类内容建议 0.8 ~ 1.0,慢一点让用户消化
pitch(音调)0.5 ~ 2.01.0声音的高低,值越高越尖默认 1.0 就好,不用调
volume(音量)0 ~ 1510声音大小跟随系统音量就好,不用单独调

7.2 默认配置

private config: TTSConfig = {
  language: 'zh-CN',
  speed: 1.0,
  pitch: 1.0,
  volume: 10
};

为什么这样设置默认值?

  • 语言默认中文:因为应用主要面向中文用户
  • 语速默认 1.0:标准语速,用户可以自己调
  • 音调默认 1.0:最自然的音调
  • 音量默认 10:中等偏上,保证能听清

7.3 更新配置

public updateConfig(partial: Partial<TTSConfig>): void {
  if (partial.language !== undefined) {
    this.config.language = partial.language;
    // 语言变了需要重建引擎
    this.recreateEngine();
  }
  if (partial.speed !== undefined) {
    this.config.speed = partial.speed;
  }
  if (partial.pitch !== undefined) {
    this.config.pitch = partial.pitch;
  }
  if (partial.volume !== undefined) {
    this.config.volume = partial.volume;
  }
}

为什么不直接用 Object.assign?

因为 ArkTS 对 JavaScript 的动态特性支持有限,Object.assign 可能不支持或者有性能问题。手动逐字段赋值虽然啰嗦,但最稳。

Partial 的妙用:

Partial<TTSConfig> 表示"所有字段都是可选的 TTSConfig"。这样调用方可以只传要改的字段:

// 只改语速
ttsService.updateConfig({ speed: 1.5 });

// 只改语言
ttsService.updateConfig({ language: 'en-US' });

// 三个都改
ttsService.updateConfig({ language: 'zh-CN', speed: 1.2, volume: 8 });

不用为每种组合写一个方法,一个方法搞定。

7.4 语言切换与引擎重建

重要:语言变了之后,需要重建 TTS 引擎,因为引擎是绑定语言的。

private async recreateEngine(): Promise<void> {
  // 先停掉当前播放
  this.stop();

  // 销毁旧引擎
  if (this.ttsEngine) {
    try {
      this.ttsEngine.shutdown();
    } catch (e) {
      hilog.error(DOMAIN, 'TTSService', `shutdown old engine failed: ${JSON.stringify(e)}`);
    }
    this.ttsEngine = null;
  }

  // 创建新引擎
  try {
    await this.createEngine();
    this.isDemoMode = false;
    hilog.info(DOMAIN, 'TTSService', 'Engine recreated successfully');
  } catch (e) {
    hilog.warn(DOMAIN, 'TTSService',
      `Recreate engine failed, fallback to demo mode: ${JSON.stringify(e)}`);
    this.isDemoMode = true;
  }
}

重建流程:

  1. 停止当前播放(如果正在播)
  2. 销毁旧引擎
  3. 创建新引擎(用新的语言配置)
  4. 失败则降级到 demo mode

⚠️ 注意:重建引擎是异步操作,期间调用 speak 可能会有问题。可以加个"正在重建"的状态,期间的请求要么排队要么走降级模式。

7.5 配置生效时机

注意updateConfig 只是改了内存里的 this.config,下一次 speak() 的时候才会生效。当前正在播放的不会被打断。

这是合理的设计——用户在设置页调整语速,不用立即打断正在听的内容,下次朗读时自动用新语速。

那如果想立即生效呢?

可以提供一个 applyConfigImmediately 方法,改完配置后停止当前播放,重新开始。但一般不建议这样做,体验不好。

八、核心方法:speak 与 stop

8.1 speak 方法详解

public speak(text: string, callback?: TTSCallback): void {
  // 1. 参数校验
  if (!text || text.trim().length === 0) {
    hilog.warn(DOMAIN, 'TTSService', 'speak called with empty text');
    return;
  }

  // 2. 防重入
  if (this.isSpeaking()) {
    hilog.warn(DOMAIN, 'TTSService', 'speak called while already speaking, ignored');
    return;
  }

  // 3. 保存回调
  this.currentCallback = callback || {};

  // 4. 降级模式处理
  if (this.isDemoMode || !this.ttsEngine) {
    this.speakInDemoMode(text);
    return;
  }

  // 5. 真实引擎播报
  try {
    const speakParams: textToSpeech.SpeakParams = {
      requestId: this.generateRequestId(),
      extraParams: { 'audioType': 'pcm' }
    };

    // 通过 extraParams 传递语速、音调和音量
    speakParams.extraParams!['speed'] = this.config.speed;
    speakParams.extraParams!['pitch'] = this.config.pitch;
    speakParams.extraParams!['volume'] = this.config.volume;

    this.ttsEngine.speak(text, speakParams);
  } catch (e) {
    hilog.error(DOMAIN, 'TTSService', `speak exception: ${JSON.stringify(e)}`);
    this.currentState = TTSState.ERROR;
    this.currentCallback?.onError?.(-1, JSON.stringify(e));
  }
}

speak 方法的五层防护:

层级作用防御什么问题
参数校验空文本直接拒绝,不往下走空指针、空字符串导致的异常
防重入正在播放就忽略新请求多个声音重叠、状态混乱
降级模式引擎不可用就走模拟模式引擎初始化失败、设备不支持
try-catch引擎调用异常也不崩引擎内部错误、状态异常
错误回调出错了通知调用方调用方不知道出错了,UI 不更新

8.2 stop 方法

public stop(): void {
  if (this.isDemoMode) {
    this.currentState = TTSState.IDLE;
    this.currentCallback?.onStop?.();
    return;
  }

  if (this.ttsEngine) {
    try {
      this.ttsEngine.stop();
    } catch (e) {
      hilog.error(DOMAIN, 'TTSService', `stop exception: ${JSON.stringify(e)}`);
    }
  }
}

设计要点:

  • 降级模式也要能 stop(状态直接切回 IDLE,触发 onStop 回调)
  • 真实引擎 stop 也要 try-catch,防止在错误状态下调用 stop 又抛异常
  • stop 是幂等的:调用多次也不会有问题

8.3 生成请求 ID

private generateRequestId(): string {
  return `tts_${Date.now()}_${Math.random().toString(36).substring(2, 8)}`;
}

每个朗读请求都有一个唯一 ID,用于日志追踪和问题排查。

格式:tts_时间戳_随机字符串,既保证唯一性,又能从 ID 看出请求时间。

为什么需要请求 ID?

  1. 日志追踪:出问题时可以根据 requestId 找到对应的日志
  2. 多请求区分:如果同时有多个请求(虽然我们防重入了),可以区分是哪个
  3. 链路追踪:和后端的 traceId 对应起来,全链路排查问题

九、长文本朗读的实现方案

民族图鉴中,有些民族的介绍文字可能比较长(几千字),而 TTS 引擎对单次朗读的文本长度是有限制的(一般是几千到几万字符)。这时候就需要分段朗读。

9.1 为什么需要长文本支持

问题说明
长度限制单次朗读文本过长,引擎可能报错或截断
进度丢失用户听到一半退出了,下次进来要从头开始
无法定位用户想从某一段开始听,只能从头等
没有高亮听的时候不知道读到哪儿了
暂停/继续原生 API 可能不支持暂停,或者支持不好

9.2 分段策略

长文本朗读的第一步是把长文本切成合适的小段。

分段原则:

  1. 按句子分:优先在句号、问号、感叹号处切分
  2. 按段落分:段落之间自然停顿,适合作为分段点
  3. 长度适中:每段 50-200 字,太长了不好控制,太短了切换频繁
  4. 避免中间切断:不要在一个句子中间切断
interface TextSegment {
  id: number;           // 段序号
  text: string;         // 文本内容
  startIndex: number;   // 在原文中的起始位置
  endIndex: number;     // 在原文中的结束位置
}

function splitLongText(text: string, maxLen: number = 150): TextSegment[] {
  const segments: TextSegment[] = [];
  let currentIndex = 0;

  while (currentIndex < text.length) {
    // 取 maxLen 长度的文本
    let endIndex = Math.min(currentIndex + maxLen, text.length);

    // 如果没到末尾,往前找到最近的句号/问号/感叹号
    if (endIndex < text.length) {
      // 在 [currentIndex, endIndex] 范围内从后往前找句末标点
      let cutPoint = endIndex;
      for (let i = endIndex; i > currentIndex; i--) {
        const char = text[i];
        if (char === '。' || char === '!' || char === '?' ||
            char === '.' || char === '!' || char === '?') {
          cutPoint = i + 1; // 包含标点
          break;
        }
      }
      endIndex = cutPoint;
    }

    const segmentText = text.substring(currentIndex, endIndex);
    segments.push({
      id: segments.length,
      text: segmentText,
      startIndex: currentIndex,
      endIndex: endIndex
    });

    currentIndex = endIndex;
  }

  return segments;
}

9.3 断点续播

用户听到一半退出了,下次进来可以从上次的位置继续听。

interface PlaybackProgress {
  textId: string;       // 文本唯一标识(比如民族ID)
  segmentIndex: number; // 当前读到第几段
  position: number;     // 在本段中的位置(可选,用于更精确的恢复)
  timestamp: number;    // 保存时间
}

class LongTextPlayer {
  private currentSegments: TextSegment[] = [];
  private currentSegmentIndex: number = 0;
  private currentTextId: string = '';

  // 保存进度
  private saveProgress(): void {
    const progress: PlaybackProgress = {
      textId: this.currentTextId,
      segmentIndex: this.currentSegmentIndex,
      position: 0,
      timestamp: Date.now()
    };
    StorageService.getInstance().saveTTSProgress(progress);
  }

  // 恢复进度
  public async resume(textId: string, fullText: string): Promise<boolean> {
    const progress = StorageService.getInstance().getTTSProgress(textId);
    if (!progress) return false;

    // 重新分段
    this.currentSegments = splitLongText(fullText);
    this.currentTextId = textId;
    this.currentSegmentIndex = progress.segmentIndex;

    // 从断点处开始播放
    this.playFromSegment(this.currentSegmentIndex);
    return true;
  }
}

9.4 高亮跟随

听的时候,正在读的那段文字高亮显示,让用户知道读到哪儿了。

// 页面中使用
@Component
struct LongTextView {
  @State segments: TextSegment[] = [];
  @State currentSegmentIndex: number = -1;
  private ttsService: TTSEngineService = TTSEngineService.getInstance();

  build() {
    Column() {
      ForEach(this.segments, (segment, index) => {
        Text(segment.text)
          .fontColor(index === this.currentSegmentIndex ? '#409EFF' : '#333333')
          .backgroundColor(index === this.currentSegmentIndex ? '#ECF5FF' : 'transparent')
      })
    }
  }

  // 播放时更新当前段索引
  private playWithHighlight(segments: TextSegment[]): void {
    this.segments = segments;
    this.currentSegmentIndex = 0;

    // 自定义一个支持分段的播放方法
    this.playSegments(segments, 0);
  }

  private playSegments(segments: TextSegment[], index: number): void {
    if (index >= segments.length) return;

    this.currentSegmentIndex = index;

    this.ttsService.speak(segments[index].text, {
      onComplete: () => {
        // 本段读完,自动读下一段
        this.playSegments(segments, index + 1);
      }
    });
  }
}

📝 民族图鉴的实现考量:目前民族介绍的长度一般在几百字以内,用原生的单次朗读就够了。分段朗读、断点续播、高亮跟随这些功能可以作为 v2.0 的优化点。先把基础功能做扎实,再逐步增强。

十、多语言 TTS 支持

民族图鉴是一个面向多语言用户的应用,TTS 自然也要支持多语言。

10.1 支持的语言列表

语言language 代码推荐音色说明
简体中文zh-CN聆小珊(女)/ 凌飞哲(男)主要语言
英语en-USLaura(女)国际版默认
日语ja-JP-可选
韩语ko-KR-可选

民族图鉴优先支持中英双语,其他语言后续根据用户需求再加。

10.2 自动语言切换

当用户在设置中切换语言时,TTS 引擎也要跟着切换。

// 在 I18nService 中监听语言变化
class I18nService {
  // ...

  public setLanguage(lang: string): void {
    // 切换应用语言
    // ...

    // 通知 TTS 服务切换语言
    TTSEngineService.getInstance().updateConfig({ language: lang });
  }
}

10.3 语言自动检测

有些时候,文本的语言和系统语言可能不一致——比如系统是中文,但要读一段英文。

理想方案:自动检测文本语言,选择对应的 TTS 引擎。

简化方案:根据文本内容简单判断,或者统一用中文引擎读(反正中文引擎也能读英文,只是口音重一点)。

民族图鉴目前采用简化方案——民族名称和介绍都是跟系统语言走的,不会出现"中文系统读英文内容"的情况。

10.4 方言支持(拓展)

中国有七大方言区,有些用户可能更习惯听方言版的民族介绍。

方言说明实现难度
粤语(广东话)使用人数多,资源丰富
四川话西南官话代表
东北话东北官话低(接近普通话)
闽南话闽语代表
客家话客家民系

实现方式:

  • 在线 TTS 引擎如果支持方言语色,直接配置
  • 不支持的话,可以用第三方 TTS 服务
  • 或者自己录制方言音频(成本高,但质量最好)

🎯 民族图鉴的特色点:56 个民族中有很多有自己的语言。如果能支持民族语言朗读(比如藏语、维吾尔语、蒙古语),那绝对是一大特色!但技术难度和成本也会高很多,需要专业的语料和合成模型。可以作为 v3.0 的长期规划。

十一、降级策略深度解析

这是整个 TTSEngineService 设计中最精彩的部分——优雅降级。

11.1 为什么需要降级

TTS 引擎不是什么时候都能用的:

  • 模拟器上可能没有 TTS 能力
  • 某些低端设备不支持
  • 网络不好时在线引擎用不了
  • 用户禁用了语音权限
  • 引擎初始化失败(各种未知原因)
  • 语言不支持(比如要读藏语但引擎没有)

如果 TTS 用不了应用就崩了,那就本末倒置了——TTS 只是增强功能,不是核心功能。

11.2 降级层级设计

我们可以设计多级降级策略,一级不行试下一级:

Level 0: 真实在线引擎(最佳体验)
    ↓ 失败
Level 1: 真实离线引擎(无需网络)
    ↓ 失败
Level 2: 系统自带 TTS(如果 Core Speech Kit 不行)
    ↓ 失败
Level 3: Demo Mode 模拟模式(UI 正常交互)

民族图鉴目前实现了 Level 0 + Level 3,中间两级可以后续加。

11.3 降级模式实现

private speakInDemoMode(text: string): void {
  hilog.info(DOMAIN, 'TTSService',
    `[Demo Mode] Playing: ${text.substring(0, 50)}...`);
  this.currentState = TTSState.SPEAKING;
  this.currentCallback?.onStart?.();

  // 模拟播放时长:根据文本长度估算(约3-8秒)
  const duration = Math.min(Math.max(text.length * 30, 2000), 8000);

  setTimeout(() => {
    if (this.currentState === TTSState.SPEAKING) {
      this.currentState = TTSState.IDLE;
      this.currentCallback?.onComplete?.();
    }
  }, duration);
}

降级模式的设计目标:

  1. 对外表现一致:同样有 onStart、onComplete、onStop 回调
  2. 状态流转一致:同样有 IDLE → SPEAKING → IDLE 的状态变化
  3. 时间合理:根据文本长度估算时长,不是固定值
  4. 可被停止:调用 stop() 能提前结束,和真实引擎行为一致
  5. 可检测:提供 isDemoModeActive() 方法,上层可以判断并提示用户

为什么要做得这么"真"?

因为页面层不需要区分"真实模式"还是"降级模式"。如果降级模式行为和真实模式不一样,页面就得写两套逻辑——那就失去了封装的意义。

🎯 架构思想面向接口编程——不管底层是真实 TTS 还是模拟的,对外的接口(方法签名、回调、状态)完全一致。上层代码只依赖抽象,不依赖具体实现。

11.4 降级模式的使用场景

场景用途
开发调试模拟器上也能测 UI 交互(播放按钮状态变化等)
自动化测试测试用例里不用依赖真实 TTS 引擎
设备不支持低端设备用不了 TTS,至少 UI 是正常的
演示环境没有语音能力的环境也能完整演示功能
单元测试mock TTS 服务,专注测试业务逻辑

11.5 用户提示

降级模式虽然能保证功能正常,但用户可能会困惑"怎么没声音?"。所以需要适当提示用户。

// 页面中使用
if (ttsService.isDemoModeActive()) {
  // 提示用户当前为模拟模式
  showToast('当前设备不支持语音朗读,使用模拟模式');
}

提示策略:

  • 不要一进来就弹窗提示,太打扰
  • 用户第一次点击朗读按钮时,用 toast 轻量提示
  • 设置页可以显示"TTS 状态:可用/降级"
  • 提供"如何开启 TTS"的帮助说明

十二、资源释放与生命周期

12.1 shutdown 方法

public shutdown(): void {
  if (this.ttsEngine) {
    try {
      this.ttsEngine.shutdown();
      this.ttsEngine = null;
    } catch (e) {
      hilog.error(DOMAIN, 'TTSService', `shutdown exception: ${JSON.stringify(e)}`);
    }
  }
  this.currentState = TTSState.IDLE;
  this.currentCallback = null;
}

应用退出时要调用 shutdown() 释放引擎资源。TTS 引擎是系统资源,不用了要还给系统。

为什么要主动释放?

  • 节省系统资源(内存、CPU、网络连接)
  • 避免后台偷偷耗电
  • 给其他应用腾出 TTS 引擎资源
  • 良好的公民应用行为

12.2 生命周期管理

生命周期节点操作调用位置
应用启动init() 初始化引擎EntryAbility.onCreate
进入详情页speak() 开始朗读页面 aboutToAppear / 用户点击
离开详情页stop() 停止播放页面 aboutToDisappear
应用退后台stop() 停止播放EntryAbility.onBackground
应用回前台可选:恢复播放EntryAbility.onForeground
应用退出shutdown() 释放引擎EntryAbility.onDestroy

12.3 页面联动的最佳实践

// 详情页中的朗读按钮
@Component
struct EthnicDetailPage {
  @State isPlaying: boolean = false;
  private ttsService: TTSEngineService = TTSEngineService.getInstance();

  // 播放/停止朗读
  private handleSpeakToggle(): void {
    if (this.isPlaying) {
      this.ttsService.stop();
      this.isPlaying = false;
    } else {
      this.ttsService.speak(this.ethnic.description, {
        onStart: () => { this.isPlaying = true; },
        onComplete: () => { this.isPlaying = false; },
        onStop: () => { this.isPlaying = false; },
        onError: () => { this.isPlaying = false; }
      });
    }
  }

  // 页面销毁时停止播放
  aboutToDisappear(): void {
    this.ttsService.stop();
  }
}

为什么要在页面销毁时 stop?

用户从详情页返回列表,总不能还在继续朗读吧?页面都没了,声音还在响,体验很差。

原则谁启动,谁停止——页面启动的播放,页面销毁时就要停掉。

⚠️ 内存泄漏提醒:如果页面销毁了不停止播放,不仅声音还在响,回调里还引用着页面的变量,可能导致页面无法被回收,造成内存泄漏。虽然 ArkTS 有 GC,但这种"正在运行的异步回调持有页面引用"的情况,还是要小心。

十三、架构设计总结

13.1 设计模式一览

模式应用场景作用
单例模式全局一个 TTS 实例统一管理、节省资源
外观模式封装复杂的引擎 API简化上层调用
状态模式IDLE/SPEAKING/ERROR清晰管理播放状态
策略模式真实引擎 / Demo Mode优雅降级
回调模式onStart/onComplete 等异步过程通知
适配器模式回调转 Promise统一异步编程模型

13.2 设计原则体现

原则体现
单一职责服务只做 TTS 相关的事
开闭原则加新的降级方式不用改上层代码
依赖倒置上层依赖 TTS 抽象,不依赖具体引擎
最小知识页面只需要知道 speak/stop,不用知道引擎细节
优雅降级TTS 不可用时不影响核心功能
防御式编程参数校验、防重入、try-catch 多层防护

13.3 服务分层设计

┌─────────────────────────────────────────┐
│          页面层 (Pages)                 │
│  详情页 / 设置页 / 列表页               │
├─────────────────────────────────────────┤
│       TTSEngineService(服务层)        │
│  ┌──────────────────────────────────┐  │
│  │  状态管理 / 配置管理 / 防重入    │  │
│  │  回调分发 / 降级策略 / 长文本    │  │
│  └──────────────────────────────────┘  │
├─────────────────────────────────────────┤
│    Core Speech Kit(系统能力层)        │
│  TextToSpeechEngine + SpeakListener    │
└─────────────────────────────────────────┘

各层职责:

  • 页面层:只管调用 speak/stop,更新 UI 状态
  • 服务层:封装复杂性,提供简洁易用的接口
  • 系统能力层:HarmonyOS 提供的底层 TTS 能力

十四、常见问题与避坑

14.1 在线 vs 离线引擎

类型优点缺点
在线引擎音色多、质量好、支持多语言需要网络、有延迟、耗流量
离线引擎不用网络、响应快音色少、质量一般、体积大

本项目选择:优先在线引擎(online: 1)。如果离线效果更好,可以改成优先离线。

最佳实践:可以设计"智能切换"策略——WiFi 下用在线,移动网络下用离线,或者让用户自己选。

14.2 文本长度限制

TTS 引擎对单次朗读的文本长度有限制(一般是几千到几万字符)。超长文本怎么办?

方案:按句子/段落切片,依次朗读。详细实现参考第九章"长文本朗读的实现方案"。

本项目的民族介绍一般不会太长,暂时不用考虑。但如果以后加了"民族故事"之类的长文本内容,就需要了。

14.3 音频焦点

多个音频源(音乐、TTS、视频)同时播放会打架。系统有音频焦点机制——谁获得焦点谁播放,其他人暂停。

本项目因为比较简单,暂时没处理音频焦点。如果以后加了音乐播放,就要考虑 TTS 和音乐的焦点冲突问题。

音频焦点的处理策略:

场景策略体验
音乐播放中,TTS 开始音乐暂停,TTS 说完后音乐继续好("闪避"效果)
音乐播放中,TTS 开始音乐音量降低,TTS 说完后恢复更好("鸭子"效果)
TTS 播放中,音乐开始音乐等 TTS 说完再播一般

14.4 错误码处理

TTS 引擎的 onError 回调会返回错误码,不同的错误码代表不同原因:

  • 网络错误
  • 引擎忙
  • 文本过长
  • 不支持的语言
  • 权限不足

可以根据错误码给用户不同的提示(比如网络错就提示"请检查网络",语言不支持就提示"暂不支持该语言")。本项目简化处理,统一回调错误码和信息。

常见错误码参考:

错误码含义处理建议
1001网络错误提示检查网络,尝试切换离线引擎
1002引擎忙稍后重试,或者排队等待
1003文本过长分段朗读
1004不支持的语言提示用户,降级到默认语言
1005权限不足引导用户开启权限
9999未知错误降级到 demo mode

14.5 内存泄漏

TTS 服务中最容易出现内存泄漏的地方:

  1. 回调引用页面:currentCallback 持有页面引用,页面销毁了没清空

    • 解决:页面 aboutToDisappear 时 stop,回调中不要持有页面强引用
  2. 定时器没清理:demo mode 用了 setTimeout,stop 的时候没清掉

    • 解决:记录 timerId,stop 时 clearTimeout
  3. 监听器没移除:如果用了观察者模式,页面销毁了没移除监听

    • 解决:页面 onDisappear 时 removeListener

十五、性能优化与最佳实践

15.1 初始化时机优化

不要在应用启动的同步流程里初始化 TTS,会拖慢启动速度。

// ❌ 不好:阻塞启动
async onCreate() {
  await TTSEngineService.getInstance().init(); // 等它完成
}

// ✅ 好:后台异步初始化
onCreate() {
  TTSEngineService.getInstance().init(); // 不用 await
}

15.2 预加载策略

如果知道用户接下来可能要用到 TTS,可以提前初始化好。

比如:

  • 进入民族列表页时,预初始化 TTS
  • 用户滑动到某张卡片时,预加载对应的文本(如果是长文本)

但不要过度预加载——用不上反而浪费资源。

15.3 避免频繁创建销毁引擎

引擎创建是有成本的(时间、资源),不要频繁创建销毁。

不好的做法:每次进入详情页都创建引擎,离开就销毁
好的做法:应用启动时创建一次,全局共用,退出时再销毁

15.4 日志规范

TTS 服务的日志要打全、打好,出问题时好排查。

// ✅ 好的日志:有 requestId、有状态、有错误详情
hilog.info(DOMAIN, 'TTSService',
  `speak start: requestId=${requestId}, textLen=${text.length}`);

// ❌ 不好的日志:信息太少
hilog.info(DOMAIN, 'TTSService', 'speak called');

关键日志点:

  • 引擎初始化(成功/失败、耗时)
  • 每次 speak(requestId、文本长度、配置参数)
  • 状态变化(IDLE → SPEAKING → IDLE)
  • 错误发生(错误码、错误信息、requestId)
  • 降级触发(什么原因降级的)

十六、小结

技术点核心要点难度
TTS 原理文本分析 → 韵律生成 → 语音合成⭐⭐⭐
系统能力Core Speech Kit 的 textToSpeech 引擎⭐⭐⭐
单例模式全局唯一 TTS 实例,统一管理⭐⭐
状态机IDLE/SPEAKING/ERROR 三状态流转⭐⭐⭐
回调封装onStart/onComplete/onStop/onError⭐⭐⭐
配置管理language/speed/pitch/volume + Partial⭐⭐
长文本朗读分段策略、断点续播、高亮跟随⭐⭐⭐⭐
多语言支持中英切换、自动语言检测⭐⭐⭐
降级策略Demo Mode 优雅降级,行为一致⭐⭐⭐⭐
资源释放shutdown 释放引擎资源⭐⭐
设计模式单例/外观/状态/策略 多种模式结合⭐⭐⭐⭐

TTSEngineService 是一个"麻雀虽小,五脏俱全"的服务——代码量不算大,但包含了服务层设计的很多核心思想:单例、状态管理、事件回调、优雅降级、资源释放…

把这个服务吃透,你就掌握了 HarmonyOS 服务封装的大半武功。

下一篇我们继续看 MusicService,看看音乐播放服务又有哪些设计巧思。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值