最近在开发鸿蒙app时刚好有文本转语音的需求,那今天我们来聊一聊如何实现
废话不多说,我们直接上代码,边看代码边聊
📚 官方参考文档
官方参考文档:文档中心
🚀 初始实现
一开始我直接将官方示例改造成工具类来使用时:
typescript
import { textToSpeech } from '@kit.CoreSpeechKit';
import type { BusinessError } from '@kit.BasicServicesKit';
class TextSpeaker {
ttsEngine: textToSpeech.TextToSpeechEngine;
createCount: number = 0;
createByCallback(originalText: string) {
let extraParam: Record<string, string> = { 'style': 'interaction-broadcast', 'locate': 'CN', 'name': 'EngineName' };
let initParamsInfo: textToSpeech.CreateEngineParams = {
language: 'zh-CN',
person: 0,
online: 1,
extraParams: extraParam
};
textToSpeech.createEngine(initParamsInfo,
(err: BusinessError, textToSpeechEngine: textToSpeech.TextToSpeechEngine) => {
if (!err) {
this.ttsEngine = textToSpeechEngine;
this.createCount++;
this.speak(originalText);
} else {
console.log('Fail to createEngine.');
}
});
};
speak(originalText: string) {
let speakListener: textToSpeech.SpeakListener = {
onStart(requestId: string, response: textToSpeech.StartResponse) {
console.log('onStart');
AppStorage.setOrCreate('isPlaying', true);
},
onComplete(requestId: string, response: textToSpeech.CompleteResponse) {
console.log('onComplete');
AppStorage.setOrCreate('isPlaying', true);
},
onStop(requestId: string, response: textToSpeech.StopResponse) {
console.log('onStop');
AppStorage.setOrCreate('isPlaying', false);
this.ttsEngine?.shutdown();
},
onData(requestId: string, audio: ArrayBuffer, response: textToSpeech.SynthesisResponse) {
console.log('onData');
},
onError(requestId: string, errorCode: number, errorMessage: string) {
console.log('onError');
AppStorage.setOrCreate('isPlaying', false);
this.ttsEngine?.shutdown();
}
};
this.ttsEngine?.setListener(speakListener);
let extraParam: Record<string, string | number> = {
'queueMode': 0,
'speed': 1,
'volume': 2,
'pitch': 1,
'languageContext': 'zh-CN',
'audioType': 'pcm',
'soundChannel': 3,
'playType': 1
}
let speakParams: textToSpeech.SpeakParams = {
requestId: '123456-a',
extraParams: extraParam
};
this.ttsEngine?.speak(originalText, speakParams);
};
shutdown() {
try {
const isBusy = this.ttsEngine?.isBusy();
if (isBusy) {
this.ttsEngine?.stop();
AppStorage.setOrCreate('isPlaying', false);
this.ttsEngine?.shutdown();
}
} catch (err) {
const error: BusinessError = err as BusinessError;
console.error(`The ttsEngine invoke error, the code is ${error.code}, the message is ${error.message}`);
}
}
}
export const textSpeaker = new TextSpeaker()
❌ 遇到的问题
存在以下报错:
less
日志显示如下错误:
08-25 12:23:46.643 4505-4771 C02100/ngtuzhi...awei/AI_INNER hongtuzhi...a.huawei E [tts_speak_async_callback.cpp(OnError:80)]parse result OnError code 50011
08-25 12:23:46.644 4505-4505 C02100/ngtuzhi...uawei/AI_TEXT hongtuzhi...a.huawei I [text_to_speech.cpp(operator():1866)]onError code: 50011 messge: service not initialized utteranceId:
08-25 12:23:46.644 4505-4505 A03D00/ngtuzhi....huawei/JSAPP hongtuzhi...a.huawei I onError
08-25 12:23:47.432 4505-5735 C02504/ngtuzhi...wei/thp_extra hongtuzhi...a.huawei I ThpExtraRunCommand[107]ver:5.0.9 ThpExtraRunCommand, cmd:THP_UpdateViewsLocation, param:thp#Location#1068,2519,1163,2681#851,2519,946,2681#590,2519,729,2681#278,2519,468,2681#61,2519,156,2681#1089,1566,1170,1647#1035,1321,1170,1445#1062,189,1170,297
08-25 12:23:49.010 4505-4505 C01406/ngtuzhi...awei/OHOS::RS hongtuzhi...a.huawei E FlushImplicitTransaction return, [renderServiceClient_:1, transactionData empty:1]
🔍 问题原因与现象复盘
- 从日志中看两次回调 onError code: 50011,message: service not initialized,说明第二次点击"播放"时,底层 TTS 服务未处于可用状态(通常是引擎已被关闭或 speak 在引擎尚未初始化完成时调用)。
- 现有实现中有两个隐患:
- 在 SpeakListener 的 onStop/onError 回调里调用了 this.ttsEngine?.shutdown(),会把引擎直接关掉,导致再次点击时复用到了已关闭的引擎句柄,从而出现 service not initialized。
- createByCallback 每次都创建引擎,但在引擎初始化异步回调返回前如果再次点击,可能出现竞态或复用未初始化完成的引擎。
✅ 改进的工具类
更严格的TTS 引擎的生命周期管理
1. 引擎单例与创建状态
- 将 ttsEngine 改为可选属性,并新增 creating 标志,防止多次快速点击导致重复创建或竞态。
- 如果已有 ttsEngine,直接复用播放,不再重复创建。
- 如果正在创建中,直接返回,等待首次创建完成后播放。
2. 播放监听器
- SpeakListener 全部改为箭头函数,规避 this 指向问题。
- 移除了 onStop/onError 中的 shutdown 调用,避免把引擎误关导致下次播放 service not initialized。
- onStart/onComplete 仅维护 AppStorage 中的 isPlaying 状态。
3. speak 调用兜底
- 如果 ttsEngine 尚未就绪,speak 会回退调用 createByCallback 再次创建,避免出现"还没初始化就 speak"的情况。
4. shutdown 行为
- shutdown() 现在会:若忙则先 stop,再 shutdown,并将 this.ttsEngine 置为 undefined,确保下次点击会重新创建引擎;同时重置 isPlaying=false。
5. 异常处理
- createEngine 失败和 speak 失败会打印错误并复位 isPlaying,避免 UI 卡在播放中状态。
typescript
import { textToSpeech } from '@kit.CoreSpeechKit';
import type { BusinessError } from '@kit.BasicServicesKit';
class TextSpeaker {
// 改为可选并新增创建中标记,避免重复创建
ttsEngine?: textToSpeech.TextToSpeechEngine;
createCount: number = 0;
private creating: boolean = false;
createByCallback(originalText: string) {
// 如果已有引擎,直接复用播放
if (this.ttsEngine) {
this.speak(originalText);
return;
}
// 避免在创建中重复点击导致多次创建
if (this.creating) {
return;
}
this.creating = true;
const extraParam: Record<string, string> = { 'style': 'interaction-broadcast', 'locate': 'CN', 'name': 'EngineName' };
const initParamsInfo: textToSpeech.CreateEngineParams = {
language: 'zh-CN',
person: 0,
online: 1,
extraParams: extraParam
};
textToSpeech.createEngine(initParamsInfo,
(err: BusinessError, textToSpeechEngine: textToSpeech.TextToSpeechEngine) => {
this.creating = false;
if (!err) {
this.ttsEngine = textToSpeechEngine;
this.createCount++;
this.speak(originalText);
} else {
console.error('Fail to createEngine.');
AppStorage.setOrCreate('isPlaying', false);
}
});
};
speak(originalText: string) {
const engine = this.ttsEngine;
if (!engine) {
// 引擎未就绪时兜底再创建
this.createByCallback(originalText);
return;
}
const speakListener: textToSpeech.SpeakListener = {
onStart: (requestId: string, response: textToSpeech.StartResponse) => {
console.log('onStart');
AppStorage.setOrCreate('isPlaying', true);
},
onComplete: (requestId: string, response: textToSpeech.CompleteResponse) => {
console.log('onComplete');
AppStorage.setOrCreate('isPlaying', false);
},
onStop: (requestId: string, response: textToSpeech.StopResponse) => {
console.log('onStop');
AppStorage.setOrCreate('isPlaying', false);
// 不在回调里关闭引擎,避免下次播放 service not initialized
},
onData: (requestId: string, audio: ArrayBuffer, response: textToSpeech.SynthesisResponse) => {
console.log('onData');
},
onError: (requestId: string, errorCode: number, errorMessage: string) => {
console.log('onError');
AppStorage.setOrCreate('isPlaying', false);
// 不在回调里关闭引擎,必要时由外部显式调用 shutdown()
}
};
engine.setListener(speakListener);
const extraParam: Record<string, string | number> = {
'queueMode': 0,
'speed': 1,
'volume': 2,
'pitch': 1,
'languageContext': 'zh-CN',
'audioType': 'pcm',
'soundChannel': 3,
'playType': 1
}
const speakParams: textToSpeech.SpeakParams = {
requestId: 'hongtu'+Date.now(),
extraParams: extraParam
};
try {
engine.speak(originalText, speakParams);
} catch (err) {
const error: BusinessError = err as BusinessError;
console.error(`Speak error, code ${error.code}, message ${error.message}`);
AppStorage.setOrCreate('isPlaying', false);
}
};
shutdown() {
try {
if (this.ttsEngine) {
const isBusy: boolean = this.ttsEngine.isBusy();
if (isBusy) {
this.ttsEngine.stop();
}
AppStorage.setOrCreate('isPlaying', false);
this.ttsEngine.shutdown();
// 置空引用,确保下次点击会重新创建引擎
this.ttsEngine = undefined;
}
} catch (err) {
const error: BusinessError = err as BusinessError;
console.error(`The ttsEngine invoke error, the code is ${error.code}, the message is ${error.message}`);
}
}
}
export const textSpeaker = new TextSpeaker()
📱 在目标页面中使用示例
scss
import { FormData, iGRouter, textSpeaker } from "basic"//导入工具类,大家自行改为相应的模块或者路径即可
@Component
export struct Voice{
@State text:string = "但使龙城飞将在,不教胡马度阴山"
@StorageLink('isPlaying') isPlaying: boolean = false;
aboutToDisappear() {
textSpeaker.shutdown()
}
private toggleVoicePlay(): void {
if (this.isPlaying) {
textSpeaker.shutdown()
} else {
textSpeaker.speak(text)
}
}
build() {
Row(){
Button(this.isPlaying ? '⏸️ 暂停播放' : '▶️ 开始播放')
.fontSize(16)
.fontWeight(FontWeight.Medium)
.backgroundColor(this.isPlaying ?
'linear-gradient(135deg, #F44336 0%, #D32F2F 100%)' :
'linear-gradient(135deg, #4CAF50 0%, #388E3C 100%)')
.fontColor('#FFFFFF')
.onClick(() => this.toggleVoicePlay())
}
}
}
🧹 Markdown文本过滤
但我播放的是markdown文本(AI的回复),我可不想用户在使用时听见一堆井号星号,所以我们还得添加一个文本过滤函数
typescript
// 将 Markdown 内容清洗为更适合 TTS 的可读文本
private sanitizeMarkdownForTTS(input: string): string {
let out: string = input
// 移除代码块围栏及其内容
out = out.replace(/```[\s\S]*?```/g, '')
// 行内代码去掉反引号
out = out.replace(/`([^`]+)`/g, '$1')
// 图片语法:保留替代文本
out = out.replace(/!\[([^\]]*)\]\([^\)]*\)/g, '$1')
// 链接语法:仅保留可读文本
out = out.replace(/\[([^\]]+)\]\([^\)]*\)/g, '$1')
// 标题:去掉#号
out = out.replace(/^\s*#{1,6}\s*/gm, '')
// 引用块:去掉>
out = out.replace(/^\s*>\s?/gm, '')
// 加粗/斜体/删除线:去掉标记
out = out.replace(/\*\*([^*]+)\*\*/g, '$1')
out = out.replace(/__([^_]+)__/g, '$1')
out = out.replace(/\*([^*]+)\*/g, '$1')
out = out.replace(/_([^_]+)_/g, '$1')
out = out.replace(/~~([^~]+)~~/g, '$1')
// 列表项:统一用圆点开头
out = out.replace(/^\s*[-+*]\s+/gm, '· ')
out = out.replace(/^\s*\d+\.\s+/gm, '')
// 表格:去掉分隔行,竖线替换为顿号
out = out.replace(/^\s*\|?\s*:?-+:?\s*(\|\s*:?-+:?\s*)+\|?\s*$/gm, '')
out = out.replace(/\s*\|\s*/g, ',')
// HTML 标签
out = out.replace(/<[^>]+>/g, '')
// 常见实体
out = out.replace(/ /g, ' ')
.replace(/&/g, '&')
.replace(/</g, '<')
.replace(/>/g, '>')
.replace(/"/g, '"')
.replace(/'/g, "'")
// 规范空白与换行
out = out.replace(/[\t\x0B\f\r]+/g, ' ')
out = out.replace(/\n{3,}/g, '\n\n').trim()
return out
}
🔄 Tab页面切换时停止播放
如果播放语音的页面是一个tab页,期望在切换tab页时语音播放停止,仅靠
typescript
aboutToDisappear() {
textSpeaker.shutdown()
}
并无法实现,因为使用 Tabs 切换时组件只是隐藏,不会被卸载,因此不会触发 aboutToDisappear(),所需我们可以在tab导航设置页面添加事件订阅emitter来实现:
在tab导航设置页面添加:
scss
@Builder
function EntryBuilder() {
NavDestination() {
Index()
}
.hideTitleBar(true) //取消自带的bar
}
@Entry
@Component
struct Index {
@State currentIndex: number = 0
tabList: iTabModel[] = [
{
text: '首页',
src: $r("app.media.icon_home"),
acSrc: $r("app.media.icon_home_active")
},
{
text: '语音播放',
src: $r("app.media.icon_voice"),
acSrc: $r("app.media.icon_voice_active")
},
]
tabsController: TabsController = new TabsController()
aboutToAppear(): void {
// 监听停止播放的事件
emitter.on('stopAIPlayback', () => {
textSpeaker.shutdown()
})
}
aboutToDisappear(): void {
// 移除事件监听
emitter.off('stopAIPlayback')
}// 跳转页面入口函数
build() {
Navigation(iGRouter) {
Column() {
Tabs({ controller: this.tabsController }) {
ForEach(this.tabList, (_: iTabModel, index) => {
TabContent() {
if (index == 0) {
//放对应组件
Home() //大家替换为自己编写/已存在的组件
} else {
Voice() //大家替换为自己编写/已存在的组件
}
}
})
}
.onChange((index) => {
// 如果从语音播放页切换到其他页面,停止语音播放
if (this.currentIndex === 1 && index !== 1) {
// 通过事件通知 Voice组件内的监听来停止播放(跨模块更可靠)
emitter.emit('stopAIPlayback')
// 本地兜底,若引用同一实例则直接停止
textSpeaker.shutdown()
}
// 统一在 onChange 中更新 currentIndex,保证判断使用的是切换前的旧值
this.currentIndex = index
})
.barHeight(0)
.layoutWeight(1)
Row() {
ForEach(this.tabList, (item: iTabModel, index) => {
this.barBuilder((() => {
item.index = index
return item
})())
})
}
.justifyContent(FlexAlign.SpaceAround)
.width('100%')
.shadow({ offsetY: -13, radius: 16, color: '#0A000000' })
}
.width('100%')
.height('100%')
}
.mode(NavigationMode.Stack)
.hideToolBar(true)
}
@Builder
barBuilder(item: iTabModel) {
Column({ space: 8 }) {
Image(this.currentIndex == item.index ? item.acSrc : item.src)
.width(24)
Text(item.text)
.fontSize(14)
.lineHeight(16)
.fontColor(this.currentIndex == item.index ? $r('app.color.tab_bar_active_color') :
$r('app.color.tab_bar_default_color'))
}
.margin({ top: 8 })
.onClick(() => {
// 只触发切换,不在此处直接改 currentIndex,避免 onChange 中的离开判断失效
this.tabsController.changeIndex(item.index!)
})
}
}
然后在voice组件中也添加事件订阅:
scss
aboutToAppear(): void {
// 监听停止AI播放的事件
emitter.on('stopAIPlayback', () => {
textSpeaker.shutdown()
})
}
aboutToDisappear() {
textSpeaker.shutdown()
// 移除事件监听
emitter.off('stopAIPlayback')
}