第17篇:语音合成与 AVPlayer 音频播放

引言

柚兔学伴的口语对话不仅是文字交流——AI 的每条回复都会通过语音合成(TTS)转换为音频,配合虚拟形象的口型动画播放,营造出真实的对话体验。本篇将深入剖析 TTS 语音合成 API 的集成、Base64 音频解码、AVPlayer 状态机驱动的音频播放,以及虚拟形象动画与音频的同步控制。

TTS 语音合成流程

调用 TTS API

当 AI 回复到达后,ChatPage 立即调用 TTS 将文本转为音频:

// ChatPage.ets - retrieve 回调中
if (this.chatList[this.chatList.length - 1].role === Role.ASSISTANT) {
  this.chatModel.ttsMaker(this.uid, item.content!!, this.timbre).then(async (result: string) => {
    let TEMP_AUDIO_FILE_NAME = '/temp.mp3';
    let base64Helper = new util.Base64Helper();
    let data = base64Helper.decodeSync(result)
    let path = getContext().cacheDir;
    let filePath = path + TEMP_AUDIO_FILE_NAME;
    if (fs.accessSync(filePath)) {
      fs.unlink(filePath)
    }
    const file: fs.File = await fs.open(filePath, fs.OpenMode.READ_WRITE | fs.OpenMode.CREATE);
    fs.writeSync(file.fd, data.buffer);
    fs.closeSync(file);

    this.startPlay(filePath)
  })
}

完整流程分为五步:

  1. 调用 TTS API:传入文本和音色参数,获取 Base64 编码的音频数据
  2. Base64 解码util.Base64Helper.decodeSync 将 Base64 字符串转为 Uint8Array
  3. 清理旧文件:如果 temp.mp3 已存在,先删除
  4. 写入文件:将解码后的二进制数据写入沙箱缓存目录
  5. 播放音频:调用 startPlay 启动 AVPlayer

TTS 参数构造

// ChatModel.ets
async ttsMaker(uuid: string, content: string, timbre: string): Promise<string> {
  let header: Record<string, string> = {
    "Content-Type": "application/json",
    "Authorization": `Bearer;${UrlConstants.SPEECH_TOKEN}`
  }
  let app: TtsParamsApp = {
    appid: UrlConstants.SPEECH_APP_ID,
    token: UrlConstants.SPEECH_TOKEN,
    cluster: 'volcano_tts'
  }
  let user: TtsParamsUser = {
    uid: uuid
  }
  let audio: TtsParamsAudio = {
    "voice_type": timbre,
    "encoding": "mp3",
    "compression_rate": 1,
    "rate": 24000,
    "speed_ratio": 1.0,
    "volume_ratio": 1.0,
    "pitch_ratio": 1.0,
    "emotion": "happy",
    "language": "en"
  }
  let request: TtsParamsRequest = {
    "reqid": uuid + new Date().toTimeString(),
    "text": content,
    "text_type": "plain",
    "operation": "query",
    "silence_duration": "125",
    "with_frontend": "1",
    "frontend_type": "unitTson",
    "pure_english_opt": "1"
  }

  let param: TtsParams = {
    app: app,
    user: user,
    audio: audio,
    request: request
  }

  this.loadingStatus = LoadingStatus.LOADING
  return HttpManager.getInstance()
    .requestTtsPostBody<string>({
      domain: UrlConstants.TTS_DOMAIN_URL,
      url: UrlConstants.TTS_URL,
      postBody: param,
      header: header,
    })
    .then(async (result) => {
      this.loadingStatus = LoadingStatus.SUCCESS
      return result
    })
}

TTS API 使用火山引擎的语音合成服务,参数分为四组:

  • app:应用标识(appid、token、cluster)
  • user:用户标识(uid)
  • audio:音频配置(音色、编码格式、采样率、语速等)
  • request:请求配置(文本内容、请求ID、操作类型)

音色选择

项目提供两种音色,根据用户选择的性别切换:

// ChatPage.ets - onReady 回调
if (data.gender === Gender.GIRL) {
  this.timbre = 'BV421_streaming'   // Elsa 女声
  this.name = 'Elsa'
} else {
  this.timbre = 'BV061_streaming'   // Daniel 男声
  this.name = 'Daniel'
}
  • BV421_streaming(Elsa):女性音色,适合英语口语练习
  • BV061_streaming(Daniel):男性音色,提供另一种对话风格

_streaming 后缀表示使用流式模型,延迟更低,适合实时对话场景。

Base64 解码与文件写入

TTS API 返回的是 Base64 编码的 MP3 音频数据,需要解码后写入本地文件:

let base64Helper = new util.Base64Helper();
let data = base64Helper.decodeSync(result)           // Base64 → Uint8Array
let path = getContext().cacheDir;                     // 沙箱缓存目录
let filePath = path + TEMP_AUDIO_FILE_NAME;           // /data/.../cache/temp.mp3

if (fs.accessSync(filePath)) {
  fs.unlink(filePath)                                 // 删除旧文件
}

const file: fs.File = await fs.open(filePath, fs.OpenMode.READ_WRITE | fs.OpenMode.CREATE);
fs.writeSync(file.fd, data.buffer);                   // 同步写入二进制数据
fs.closeSync(file);                                   // 关闭文件

this.startPlay(filePath)                              // 播放

关键技术点:

  • cacheDir:使用缓存目录而非持久化目录,临时音频文件无需长期保存
  • 同步写入fs.writeSync 确保数据完整写入后再播放
  • 文件清理:每次播放前删除旧的 temp.mp3,避免缓存堆积

AVPlayer 音频播放

创建播放器

async startPlay(url: string) {
  if (this.avPlayer != undefined) {
    await this.avPlayer.release();
    this.avPlayer = undefined;
  }
  // 创建AVPlayer实例对象
  this.avPlayer = await media.createAVPlayer();
  // 注册回调
  this.setAVPlayerCallback();
  try {
    let fdPath = 'fd://';
    if (!fs.accessSync(url)) {
      console.info(TAG, `=====file not exists url = ${url}======`)
    }
    let file = await fs.open(url);
    fdPath = fdPath + '' + file.fd;
    this.avPlayer.url = fdPath;
  } catch (e) {
    console.error(TAG, `=====errror = ${e}======`)
  }
}

播放前的准备工作:

  1. 释放旧实例:如果已有播放器,先 release 释放资源
  2. 创建新实例media.createAVPlayer() 创建播放器
  3. 注册回调setAVPlayerCallback 绑定状态和错误回调
  4. 设置播放源:通过 fd:// 协议指定音频文件

fd:// 协议

HarmonyOS 的 AVPlayer 使用 fd:// 协议访问本地文件:

let file = await fs.open(url);
fdPath = 'fd://' + file.fd;
this.avPlayer.url = fdPath;

avPlayer.url 赋值后,AVPlayer 自动进入 initialized 状态,触发状态机回调。这是整个播放流程的起点——不需要手动调用任何方法,赋值即启动。

AVPlayer 状态机

AVPlayer 的状态机是播放控制的核心,每个状态对应特定的合法操作:

idle → initialized → prepared → playing → completed → stopped → released
setAVPlayerCallback() {
  if (this.avPlayer != undefined) {
    this.avPlayer.on('seekDone', (seekDoneTime) => {
      console.info(`AVPlayer seek succeeded, seek time is ${seekDoneTime}`);
    })

    this.avPlayer.on('error', (err) => {
      console.error(`Invoke avPlayer failed, code is ${err.code}, message is ${err.message}`);
      this.avPlayer!!.reset();  // 错误时重置到 idle 状态
    })

    this.avPlayer.on('stateChange', async (state, reason) => {
      switch (state) {
        case 'idle':
          this.avPlayer!!.release();
          break;
        case 'initialized':
          this.avPlayer!!.prepare();
          break;
        case 'prepared':
          this.avPlayer!!.play();
          this.girlState = false    // 切换到说话动画
          break;
        case 'playing':
          break;
        case 'paused':
          this.avPlayer!!.play();
          break;
        case 'completed':
          this.girlState = true     // 切换回等待动画
          this.avPlayer!!.stop();
          break;
        case 'stopped':
          this.avPlayer!!.reset();
          break;
        case 'released':
          break;
        default:
          break;
      }
    })
  }
}

各状态详解

状态 触发条件 项目中的操作
idle 调用 reset() 调用 release() 销毁实例
initialized 设置 avPlayer.url 调用 prepare() 准备播放
prepared prepare() 完成 调用 play() 开始播放 + girlState = false
playing play() 成功 (无额外操作,音频正在播放)
paused pause() 成功 调用 play() 恢复播放
completed 音频播放结束 girlState = true + 调用 stop()
stopped stop() 成功 调用 reset() 回到 idle
released release() 完成 资源已释放,无需操作

错误处理

this.avPlayer.on('error', (err) => {
  console.error(`Invoke avPlayer failed, code is ${err.code}, message is ${err.message}`);
  this.avPlayer!!.reset();  // 调用reset重置资源,触发idle状态
})

错误回调中调用 reset() 将播放器重置到 idle 状态,进而触发 idle 回调中的 release(),完成资源释放。这确保了即使播放出错,也不会造成资源泄漏。

虚拟形象动画同步

girlState 是控制虚拟形象动画的关键状态变量:

@State girlState: boolean = true    // true=等待动画, false=说话动画
// 等待状态 GIF
Image(this.getAIUrl(this.waitingUrl))
  .visibility(this.girlState ? Visibility.Visible : Visibility.None)

// 说话状态 GIF
Image(this.getAIUrl(this.speakingUrl))
  .visibility(this.girlState ? Visibility.None : Visibility.Visible)

动画切换与 AVPlayer 状态完美同步:

  • prepared → playgirlState = false,切换到说话 GIF,模拟口型开合
  • completed → stopgirlState = true,切换回等待 GIF,AI “闭嘴”

这种基于状态机的同步方式比定时器更可靠——无论音频长短、是否出错,动画都能正确跟随音频状态变化。

资源释放

页面销毁时必须释放 AVPlayer 资源:

async aboutToDisappear() {
  if (this.avPlayer != undefined) {
    await this.avPlayer.stop();
    await this.avPlayer.release();
    this.avPlayer = undefined;
  }
}

aboutToDisappear 是组件的生命周期回调,在页面被销毁时触发。先 stoprelease,确保播放完全停止后释放资源,避免内存泄漏和音频残留。

播读诗词

PoemPage 中的"播读"按钮使用简化版的音频播放:

Button('播读')
  .onClick(() => {
    PoemReader.play(this.poemInfo?.poem ?? '');
  })

PoemReader 是 common 模块提供的封装类,内部同样使用 TTS + AVPlayer 的组合,但对诗词场景做了优化(如语速更慢、停顿更长),提供更适合古诗朗读的体验。

TTS 与对话流程的完整集成

在 ChatPage 中,TTS 与 AI 对话流程紧密集成:

// retrieve 回调中,当收到 AI 回复时
if (this.chatList[this.chatList.length - 1].role === Role.ASSISTANT) {
  // 1. 发送给帮助智能体获取学习提示
  this.sendMsgToHelpAgent(item.content!!, this.name)
  // 2. 同时播放语音
  this.chatModel.ttsMaker(this.uid, item.content!!, this.timbre).then(async (result: string) => {
    let base64Helper = new util.Base64Helper();
    let data = base64Helper.decodeSync(result)
    let path = getContext().cacheDir;
    let filePath = path + '/temp.mp3';
    if (fs.accessSync(filePath)) {
      fs.unlink(filePath)
    }
    const file: fs.File = await fs.open(filePath, fs.OpenMode.READ_WRITE | fs.OpenMode.CREATE);
    fs.writeSync(file.fd, data.buffer);
    fs.closeSync(file);
    this.startPlay(filePath)
  })
}

注意两个操作是并行的:TTS 语音播放和帮助智能体请求同时发起,不互相阻塞。这样用户在听到 AI 语音的同时,学习提示也在后台生成,体验更流畅。

小结

本篇详细介绍了柚兔学伴的语音合成与音频播放实现:

  • TTS 语音合成:调用火山引擎 API,传入文本和音色参数,获取 Base64 编码的 MP3 音频
  • Base64 解码util.Base64Helper.decodeSync 解码 → fs.writeSync 写入缓存文件
  • AVPlayer 状态机:idle → initialized → prepared → playing → completed → stopped → released,每个状态对应特定操作
  • fd:// 协议:HarmonyOS 通过文件描述符访问音频文件,赋值 avPlayer.url 即触发 initialized 状态
  • 动画同步girlState 在 prepared 时切换为说话 GIF,completed 时切回等待 GIF,与音频完美同步
  • 音色选择:BV421_streaming(Elsa 女声)和 BV061_streaming(Daniel 男声),根据用户性别偏好切换
  • 资源管理:页面销毁时 stop + release 释放 AVPlayer,避免内存泄漏
Logo

讨论HarmonyOS开发技术,专注于API与组件、DevEco Studio、测试、元服务和应用上架分发等。

更多推荐