HarmonyOS《柚兔学伴》项目实战17-语音合成与 AVPlayer 音频播放
第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)
})
}
完整流程分为五步:
- 调用 TTS API:传入文本和音色参数,获取 Base64 编码的音频数据
- Base64 解码:
util.Base64Helper.decodeSync将 Base64 字符串转为 Uint8Array - 清理旧文件:如果 temp.mp3 已存在,先删除
- 写入文件:将解码后的二进制数据写入沙箱缓存目录
- 播放音频:调用
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}======`)
}
}
播放前的准备工作:
- 释放旧实例:如果已有播放器,先
release释放资源 - 创建新实例:
media.createAVPlayer()创建播放器 - 注册回调:
setAVPlayerCallback绑定状态和错误回调 - 设置播放源:通过
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 → play:
girlState = false,切换到说话 GIF,模拟口型开合 - completed → stop:
girlState = true,切换回等待 GIF,AI “闭嘴”
这种基于状态机的同步方式比定时器更可靠——无论音频长短、是否出错,动画都能正确跟随音频状态变化。
资源释放
页面销毁时必须释放 AVPlayer 资源:
async aboutToDisappear() {
if (this.avPlayer != undefined) {
await this.avPlayer.stop();
await this.avPlayer.release();
this.avPlayer = undefined;
}
}
aboutToDisappear 是组件的生命周期回调,在页面被销毁时触发。先 stop 再 release,确保播放完全停止后释放资源,避免内存泄漏和音频残留。
播读诗词
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,避免内存泄漏
更多推荐


所有评论(0)