
文本到语音,这个 API 比你想象中麻烦
在 HarmonyOS NEXT 开发中,语音合成(TextToSpeech, TTS)是一个很容易被低估的能力。官方 Demo 通常几行代码就跑通了,但那只是"能响"而已。真正到了项目里,你会发现需要处理的事情远不止调用一个 speak() 方法:多人协作时音色冲突问题、页面返回后合成器状态丢失、在线离线模式切换时回调时序混乱,这些才是日常。
Core Speech Kit(基础语音服务) 提供了 SpeechSynthesizer 来实现文本转语音,支持在线和离线两种模式。在线模式能调用云端上百种高质量音色,离线模式则用于网络差或无网的场景,速度更快但音色选择有限。两者不冲突,可以在同一个应用中根据需要动态切换。
环境说明
text
DevEco Studio 版本:DevEco Studio 6.1.0 及以上
HarmonyOS SDK 版本:HarmonyOS 6.1.0(23) 及以上
目标设备:手机(需支持系统语音能力)
核心实现:从文本到语音,5 步完成
权限申请
语音合成需要获取 互联网权限 (用于在线音色下载)和 麦克风权限 。虽然合成过程不需要录音,但部分系统级能力会检查麦克风授权状态。在 module.json5 中添加:
json5
"requestPermissions": [
{
"name": "ohos.permission.INTERNET"
},
{
"name": "ohos.permission.MICROPHONE",
"reason": "$string:app_name"
}
]
创建合成器实例
SpeechSynthesizer 通过 createSpeechSynthesizer 创建。不要随意新建,推荐单例管理。
typescript
// SpeechManager.ts
import { speechSynthesizer, TextToSpeechCallback } from '@kit.CoreSpeechKit';
export class SpeechManager {
private static instance: SpeechManager;
private ttsEngine: speechSynthesizer.SpeechSynthesizer | null = null;
private callback: TextToSpeechCallback;
static getInstance(): SpeechManager {
if (!SpeechManager.instance) {
SpeechManager.instance = new SpeechManager();
}
return SpeechManager.instance;
}
async initEngine(): Promise<void> {
if (this.ttsEngine) return;
this.callback = {
onStart: () => console.info('TTS Started'),
onStop: () => console.info('TTS Stopped'),
onError: (errorCode) => console.error('TTS Error:', errorCode),
onPause: () => console.info('TTS Paused'),
onResume: () => console.info('TTS Resumed'),
};
// 第一个参数:语言模型类型,在线使用 'latency-first',离线使用 'offline'
this.ttsEngine = await speechSynthesizer.createSpeechSynthesizer(
{ deviceId: 'local' },
this.callback
);
}
getEngine(): speechSynthesizer.SpeechSynthesizer | null {
return this.ttsEngine;
}
}
注意 :createSpeechSynthesizer 返回的是一个 Promise,在页面 aboutToAppear 中初始化即可,不要在 build() 中做。
在线/离线模式切换
配置 SpeechSynthesisRequest 时,通过 requestType 字段决定:
typescript
// 在线模式
const onlineRequest: speechSynthesizer.SpeechSynthesisRequest = {
utteranceId: 'utterance_001',
text: '你好,HarmonyOS!',
requestType: 1, // 1 表示在线
speed: 0, // 语速,范围 -100 ~ 100
pitch: 0, // 音调,范围 -100 ~ 100
volume: 1, // 音量,0~1
voiceName: 'zh-CN-XiaoxiaoNeural' // 在线音色名称
};
// 离线模式
const offlineRequest: speechSynthesizer.SpeechSynthesisRequest = {
utteranceId: 'utterance_002',
text: '离线播报测试',
requestType: 0, // 0 表示离线
speed: 0,
pitch: 0,
volume: 1,
voiceName: '' // 离线模式通常用系统默认音色,留空
};
关键差异:
| 特性 | 在线模式 | 离线模式 |
|---|---|---|
| 网络要求 | 必须联网 | 不依赖网络 |
| 音色数量 | 上百种(如Xiaoxiao、Yunxi等) | 1~2 种系统基础音色 |
| 首字节延迟 | 500ms~2s | 50ms~200ms |
| 功能支持 | 支持SSML 、语速/音调调节 | 仅基础合成,部分参数可能无效 |
完整功能示例:音色切换 + 语速调节 + SSML
这段代码实现了一个简单的控制面板,点击按钮即可切换在线音色、调节语速,并使用 SSML 标记指定说话人。
typescript
// pages/Index.ets
import { speechSynthesizer } from '@kit.CoreSpeechKit';
import { SpeechManager } from '../SpeechManager';
@Entry
@Component
struct TTSDemo {
@State text: string = '<speak><voice name="zh-CN-XiaoxiaoNeural">欢迎使用语音合成</voice><voice name="zh-CN-YunxiNeural">您可以同时指定不同的说话人</voice></speak>';
@State speed: number = 0;
@State currentVoice: string = 'zh-CN-XiaoxiaoNeural';
@State isSpeaking: boolean = false;
private manager: SpeechManager = SpeechManager.getInstance();
aboutToAppear(): void {
this.manager.initEngine();
}
// 切换音色
switchVoice(voiceName: string): void {
const request: speechSynthesizer.SpeechSynthesisRequest = {
utteranceId: 'voice_switch',
text: this.text,
requestType: 1,
speed: this.speed,
pitch: 0,
volume: 1,
voiceName: voiceName,
ssml: `<speak><voice name="${voiceName}">这是音色切换测试</voice></speak>`
};
this.currentVoice = voiceName;
this.speakText(request);
}
// 调速度
setSpeed(val: number): void {
this.speed = val;
const request: speechSynthesizer.SpeechSynthesisRequest = {
utteranceId: 'speed_test',
text: '速度调节演示',
requestType: 1,
speed: val,
pitch: 0,
volume: 1,
voiceName: this.currentVoice
};
this.speakText(request);
}
// 执行合成
speakText(request: speechSynthesizer.SpeechSynthesisRequest): void {
const engine = this.manager.getEngine();
if (!engine) {
console.error('Engine not initialized');
return;
}
engine.stop(); // 先停止当前播放,避免叠加
engine.speak(request);
}
// 暂停/恢复
togglePlayPause(): void {
const engine = this.manager.getEngine();
if (!engine) return;
if (this.isSpeaking) {
engine.pause();
} else {
engine.resume();
}
this.isSpeaking = !this.isSpeaking;
}
build() {
Column({ space: 12 }) {
TextArea({ placeholder: '输入文本或SSML', text: this.text })
.onChange((val: string) => this.text = val)
.height(120)
Row({ space: 8 }) {
Button('Xiaoxiao').onClick(() => this.switchVoice('zh-CN-XiaoxiaoNeural'))
Button('Yunxi').onClick(() => this.switchVoice('zh-CN-YunxiNeural'))
Button('Yunyang').onClick(() => this.switchVoice('zh-CN-YunyangNeural'))
}
Text(`语速: ${this.speed}`)
Slider({ value: this.speed, min: -100, max: 100, step: 1 })
.onChange((val: number) => this.setSpeed(val))
Row({ space: 8 }) {
Button(this.isSpeaking ? '暂停' : '恢复').onClick(() => this.togglePlayPause())
Button('停止').onClick(() => {
this.manager.getEngine()?.stop();
this.isSpeaking = false;
})
}
}
.padding(16)
.width('100%')
.height('100%')
}
}
运行效果:点击音色按钮,切换不同声音朗读;拖动语速滑块,实时改变说话速度;点击暂停/恢复,控制播放流程。
常见问题 1:onStart 回调触发时机不稳定
现象 :在线模式下,onStart 有时在语音真正播放前 1~2 秒就回调了,导致 UI 状态过早切换,出现"正在朗读"但实际无声的间隙。
原因 :在线合成需要先下载音频数据,onStart 只在 首次数据到达 时回调,不代表解码或渲染完成。离线模式因为数据即时可用,回调时机更准。
解决方案 :不要依赖 onStart 作为"用户听到声音"的起点。改用 onAudioAvailable 回调(如果有)或者延迟 500ms 再更新 UI 状态。更可靠的做法是监听播放状态回调,但当前 SDK 没有直接暴露"音频已输出"的事件,可结合定时器或 AudioRenderer 状态判断:
typescript
this.callback.onStart = () => {
// 延迟更新 UI,给离线/在线解码留出缓冲
setTimeout(() => {
// 更新播放状态
}, 300);
};
常见问题 2:pause / resume 在部分设备上不生效
现象 :调用 pause() 后,语音继续播放直到当前句子结束才停,而不是立即暂停。调用 resume() 后,有时会从头播放,有时正常继续。
原因 :语音合成器的 pause / resume 是 句子级别的 。合成器内部不细分到字词暂停,只会在一个完整的 Sentence 结束后才响应暂停请求。resume 行为则依赖设备驱动,部分设备会重新初始化合成器导致丢失上下文。
解决方案 :使用 stop() + 记录已播放位置来实现"伪暂停"。或者只在离线模式下使用 pause / resume,在线模式直接用 stop() 重构:
typescript
// 伪暂停方案:停止并记住最后一个 utteranceId
let lastUtteranceId = '';
let isPaused = false;
function smartPause(): void {
engine.pause();
isPaused = true;
}
function smartResume(): void {
if (isPaused) {
engine.resume();
isPaused = false;
} else {
// 如果已经 stop 过,需要重新 speak
engine.speak(createRequest(lastUtteranceId));
}
}
最佳实践
1. 不要让多个 SpeechSynthesizer 实例共存。 同一个应用内多个实例会竞争音频焦点,导致播报互相打断。推荐使用单例管理,所有语音请求都通过同一个实例执行。
2. 使用 SSML 时,text 字段和 ssml 字段只传一个。 如果两个都传,系统会优先使用 text。官方文档没有明确说明,实测发现 ssml 会被忽略。
3. 切换在线/离线模式前必须先调用 stop()。 如果当前正在在线模式播报,直接切换 requestType 发起新请求,旧请求的资源不会被立即释放,可能导致音频卡顿甚至 crash。
FAQ
Q:为什么真机正常,模拟器不生效?
A:模拟器不支持音频渲染。语音合成需要原生音频驱动,模拟器只能跑通回调逻辑,无法输出声音。必须用真机调试。
Q:为什么第一次授权失败,第二次成功?
A:MICROPHONE 权限首次安装时弹窗,如果用户点击"拒绝",系统会把应用加入"受限"列表,第二次不会再弹窗。需要在 settings 界面手动开启。建议在 onError 回调中增加权限提示。
Q:在线模式下,网络切换(4G→WiFi)后合成失败?
A:SpeechSynthesizer 内部没有网络重连机制。网络切换后,旧的连接会超时,需要重新 stop() 再 speak()。监听 网络状态变化 事件,在回调中重置合成器。
Q:页面销毁后,语音还在播放,应用直接崩溃?
A:页面 aboutToDisappear 时未释放合成器资源。建议在页面生命周期结束时调用 engine.stop() 和 engine.release()。但 release() 是异步方法,需等待完成后再退出。