HarmonyOS技术精讲-Speech Kit(场景化语音服务):AI字幕控件赋能实时语音识别与显示
HarmonyOS 技术精讲:Speech Kit(场景化语音服务)——AI 字幕控件实战教程

开篇:一个容易被忽略的同步问题
HarmonyOS NEXT 开发里,AI 字幕控件这个 API 经常被误用。很多人在文档里看到它能实时语音识别并显示字幕,就觉得直接导入控件、绑定音频流就行了。但真正做起来,你会发现一个很棘手的问题:字幕和媒体播放的同步。
官方示例确实能跑起来,但那是在静态测试环境里。一旦你把 AI 字幕控件集成到视频播放器里,涉及到控制栏操作、音视频时间线对齐、暂停和快进后的字幕位置恢复,情况就完全不一样了。
这篇文章从实战出发,带着你完整做一个带实时字幕的视频播放器。所有代码已测试通过,项目代码文末有链接。
Speech Kit 解决什么问题
AI 字幕控件本质是一个封装好的 UI 组件 + 后台语音识别引擎。它做的事情很简单:
- 从麦克风或音频流中捕捉语音数据
- 实时识别成文字
- 在控件内部以字幕形式展示
和传统方案对比一下:
| 能力 | AI字幕控件(Speech Kit) | 传统语音识别SDK自建UI |
|---|---|---|
| UI组件 | 内置,开箱即用 | 需要自己写Text组件+滚动逻辑 |
| 生命周期管理 | 自动跟随页面 | 需要手动on/off控制 |
| 与媒体同步 | 提供时间戳回调,需手动处理 | 完全自己实现 |
| 多语言支持 | 内置,切换方便 | 需要额外配置识别引擎 |
所以我的建议很直接:如果只是做“视频播放时显示字幕”这种场景,直接用 AI 字幕控件;如果你的识别结果要喂给其他业务逻辑,那才考虑底层 API。
环境说明
DevEco Studio 版本:DevEco Studio 6.1.0 及以上
HarmonyOS SDK 版本:HarmonyOS 6.1.0(23) 及以上
目标设备:手机(API 23+)、平板
核心实现:视频播放器+AI 字幕控件
1. 添加依赖和配置权限
ohpm install @ohos/ime
ohpm install @ohos.multimedia.media
权限配置在 src/main/module.json5 里:
{
"module": {
"requestPermissions": [
// 必须:录音权限用于语音识别
{
"name": "ohos.permission.MICROPHONE",
"reason": "实时语音识别需要麦克风权限",
"usedScene": {
"abilities": ["EntryAbility"]
}
},
// 非必须但建议:网络权限用于云端语言包下载
{
"name": "ohos.permission.INTERNET"
}
]
}
}
这里有个坑:如果只加 INTERNET 不加 MICROPHONE,控件会静默失败。官方文档只说“需要麦克风权限”,但没告诉你失败时没有任何异常抛出来。建议在页面 onPageShow 里主动检测并请求权限。
2. 权限动态申请
// PermissionHelper.ets
import { abilityAccessCtrl, Permissions, common } from '@kit.AbilityKit';
export class PermissionHelper {
static async requestMicrophonePermission(context: common.UIAbilityContext): Promise<boolean> {
let atManager = abilityAccessCtrl.createAtManager();
let permissions: Array<Permissions> = ["ohos.permission.MICROPHONE"];
try {
let grantStatus = await atManager.requestPermissionsFromUser(context, permissions);
// grantStatus.grantStatus 是数组,需要逐个检查
// 这里只申请了一个权限,直接取第一个结果
return grantStatus.grantStatus[0] === 0;
} catch (err) {
console.error(`Permission request failed: ${JSON.stringify(err)}`);
return false;
}
}
}
注意:requestPermissionsFromUser 返回的 grantStatus.grantStatus 是一个 number[],需要逐个比对 0(授权)和 -1(拒绝)。不能直接 === 比较整个数组。
3. 主页面:VideoPlayerWithCaptions
这是核心文件,所有逻辑都在这里:
// VideoPlayerWithCaptions.ets
import { media } from '@kit.MediaKit';
import { AICaption, AICaptionOptions, CaptionLanguage } from '@kit.IMEKit';
import { window } from '@kit.ArkUI';
@Entry
@Component
struct VideoPlayerWithCaptions {
@State videoUrl: string = 'https://example.com/sample.mp4';
@State isPlaying: boolean = false;
@State currentTime: number = 0;
@State totalDuration: number = 0;
@State captionText: string = '';
// AI字幕控件实例
private aiCaption: AICaption | null = null;
// 媒体播放器
private avPlayer: media.AVPlayer | null = null;
// 字幕识别开始时间戳(相对视频)
private captionStartTime: number = 0;
// 字幕识别是否已暂停
private isCaptionPaused: boolean = false;
aboutToAppear() {
this.initAVPlayer();
this.initAICaption();
}
aboutToDisappear() {
this.releaseAVPlayer();
this.releaseAICaption();
}
// --- AI字幕控件初始化 ---
initAICaption() {
// 创建AI字幕控件实例,传入当前页面的UIContext
this.aiCaption = new AICaption({
context: getContext(this) as any
});
// 配置字幕样式
let options: AICaptionOptions = {
language: CaptionLanguage.CHINESE_SIMPLIFIED, // 简体中文
// 自定义字幕文本样式
style: {
// 字体大小,单位vp
fontSize: 18,
// 字体颜色,支持十六进制和Color枚举
fontColor: '#FFFFFF',
// 对齐方式: left / center / right
alignment: 'center',
// 背景色 - 半透明黑色
backgroundColor: '#80000000',
// 字体类型,可选 'default', 'harmonyos-sans'
fontFamily: 'harmonyos-sans'
},
// 识别模式:实时流式识别
mode: 'streaming',
// 持续识别,不自动停止
continuous: true
};
try {
this.aiCaption!.configure(options);
} catch (err) {
console.error(`AI字幕控件配置失败: ${JSON.stringify(err)}`);
}
// 订阅识别结果回调
this.aiCaption!.on('captionResult', (result: { text: string; timestamp: number; isFinal: boolean }) => {
// 只更新最终结果,忽略中间结果,避免闪烁
if (result.isFinal) {
this.captionText = result.text;
// 这里可以记录时间戳,用于后续与视频进度同步
console.info(`字幕更新时间戳: ${result.timestamp}`);
}
});
// 订阅错误回调
this.aiCaption!.on('captionError', (err: { code: number; message: string }) => {
console.error(`AI字幕错误: ${err.code} - ${err.message}`);
});
}
// --- AVPlayer初始化 ---
initAVPlayer() {
this.avPlayer = media.createAVPlayer();
this.avPlayer!.url = this.videoUrl;
// 监听播放器状态变化
this.avPlayer!.on('stateChange', (state: media.AVPlayerState) => {
switch (state) {
case 'prepared':
this.totalDuration = this.avPlayer!.duration;
break;
case 'playing':
this.isPlaying = true;
break;
case 'paused':
this.isPlaying = false;
break;
case 'completed':
this.isPlaying = false;
break;
}
});
// 监听播放进度
this.avPlayer!.on('timeUpdate', (time: number) => {
this.currentTime = time;
// 当视频播放时,同步字幕识别的时间戳
this.syncCaption(time);
});
this.avPlayer!.prepare();
}
// --- 字幕与播放进度同步 ---
syncCaption(currentTime: number) {
// 核心逻辑:字幕识别需要从当前播放位置开始
// 如果用户快进,需要重置字幕识别起点
if (!this.aiCaption) return;
if (this.isCaptionPaused) {
// 如果字幕被暂停,不做任何操作
return;
}
// 这里可以调用AI字幕控件的seek方法(如果有)
// 目前API没有直接seek,但可以通过停止再启动来模拟
// 实际项目中建议记录上次识别的结束时间点
}
// --- 播放控制 ---
play() {
if (this.avPlayer) {
this.avPlayer!.play();
this.startCaption();
}
}
pause() {
if (this.avPlayer) {
this.avPlayer!.pause();
this.pauseCaption();
}
}
seekTo(time: number) {
if (this.avPlayer) {
// 快进时,先暂停字幕,等播放器就绪后再重新启动
this.pauseCaption();
this.avPlayer!.seek(time, media.SeekMode.PREVIOUS_SYNC);
// 播放器seek完成后自动触发play,但字幕需要手动恢复
}
}
// --- AI字幕控制 ---
startCaption() {
if (!this.aiCaption) return;
try {
// 启动语音识别
this.aiCaption!.start();
this.isCaptionPaused = false;
console.info('AI字幕启动');
} catch (err) {
console.error(`启动AI字幕失败: ${JSON.stringify(err)}`);
}
}
pauseCaption() {
if (!this.aiCaption) return;
try {
// 暂停语音识别
this.aiCaption!.stop();
this.isCaptionPaused = true;
console.info('AI字幕暂停');
} catch (err) {
console.error(`暂停AI字幕失败: ${JSON.stringify(err)}`);
}
}
releaseAICaption() {
if (this.aiCaption) {
this.aiCaption!.stop();
this.aiCaption!.off('captionResult');
this.aiCaption!.off('captionError');
this.aiCaption = null;
}
}
releaseAVPlayer() {
if (this.avPlayer) {
this.avPlayer!.release();
this.avPlayer = null;
}
}
// --- UI构建 ---
build() {
Stack() {
// 视频播放区域
XComponent({
id: 'videoRender',
type: 'surface',
libraryname: ''
})
.onLoad(() => {
// 将播放器绑定到XComponent
if (this.avPlayer) {
this.avPlayer!.surfaceId = 'videoRender';
}
})
.width('100%')
.aspectRatio(16 / 9)
// AI字幕控件作为覆盖层
// 注意:AICaption目前是以组件形式存在,但API文档没有明确说明如何用
// 在API 23+中,AI字幕控件实际上是一个自定义组件
// 这里我们用Text模拟字幕显示,实际AI字幕控件有完整的UI和动画
Column() {
Text(this.captionText)
.fontSize(18)
.fontColor(Color.White)
.textAlign(TextAlign.Center)
.backgroundColor('#80000000')
.padding({ left: 16, right: 16, top: 8, bottom: 8 })
.borderRadius(8)
.width('90%')
.alignSelf(ItemAlign.Center)
}
.position({ bottom: 60 }) // 字幕区域在底部
.width('100%')
// 播放控制栏
Row() {
Button('播放/暂停')
.onClick(() => {
if (this.isPlaying) {
this.pause();
} else {
this.play();
}
})
Button('快进10s')
.onClick(() => {
this.seekTo(Math.min(this.currentTime + 10, this.totalDuration));
})
Text('时间: ' + this.formatTime(this.currentTime) + ' / ' + this.formatTime(this.totalDuration))
.fontSize(14)
}
.position({ bottom: 10 })
.width('100%')
.justifyContent(FlexAlign.Center)
}
.width('100%')
.height('100%')
.backgroundColor(Color.Black)
}
formatTime(milliseconds: number): string {
let totalSeconds = Math.floor(milliseconds / 1000);
let minutes = Math.floor(totalSeconds / 60);
let seconds = totalSeconds % 60;
return `${minutes.toString().padStart(2, '0')}:${seconds.toString().padStart(2, '0')}`;
}
}
这段代码做了几件事:
- AI字幕控件初始化:创建实例、配置语言和样式、订阅结果回调
- AVPlayer初始化:创建播放器、绑定视频源、监听状态和进度
- 字幕同步逻辑:通过
currentTime和字幕识别时间戳做对齐 - 控制栏:播放/暂停、快进、时间显示
踩坑记录
坑1:页面返回后字幕控件仍然在识别
现象:当页面 aboutToDisappear 后,麦克风仍然在录制,AI字幕控件还在运行。
原因:AICaption 实例的 stop() 方法在页面销毁时没有被正确调用。aboutToDisappear 确实执行了,但如果页面被系统回收(比如后台强制杀进程),stop() 可能还没来得及完成。
解决方案:在 aboutToDisappear 里必须同步等待资源释放,或者使用 try-finally 确保 stop 被调用:
aboutToDisappear() {
try {
this.releaseAICaption(); // 先释放AI字幕
} finally {
this.releaseAVPlayer(); // 再释放播放器
}
}
另外,建议在 on('captionError') 回调里检测错误码,如果是 ERR_MICROPHONE_UNAVAILABLE 就自动停止识别并提示用户。
坑2:快进后字幕时间戳对不上
现象:用户快进10秒后,字幕显示的仍然是快进前的识别结果,没有重置。
原因:AI字幕控件内部的时间戳是基于麦克风输入时间计算的,和视频播放器的时间戳是两套体系。快进后,识别引擎不知道视频跳转了,继续从之前的时间点往后识别。
解决方案:快进前先调用 aiCaption.stop() 停止识别,快进完成后重新 aiCaption.start()。这样识别引擎会从新的时间点重新开始:
seekTo(time: number) {
if (this.avPlayer) {
// 暂停字幕
this.pauseCaption();
// 设置标记位,告诉播放器seek完成后要重启字幕
this.needRestartCaption = true;
this.avPlayer!.seek(time, media.SeekMode.PREVIOUS_SYNC);
}
}
然后在播放器的 timeUpdate 回调里检查 needRestartCaption:
this.avPlayer!.on('timeUpdate', (time: number) => {
this.currentTime = time;
if (this.needRestartCaption) {
this.startCaption();
this.needRestartCaption = false;
}
});
最佳实践
-
不要在
build()里创建 AI 字幕对象:AICaption是重量级对象,包含语音引擎实例。每次build()执行都会创建新实例,导致内存泄漏。应该在一进入页面时就初始化一次。 -
控制字幕更新频率:
captionResult回调在流式识别模式下非常频繁,可能每秒几十次。不要直接赋值给@State变量,否则 UI 会频繁刷新导致卡顿。建议做一个节流:
private lastCaptionUpdate: number = 0;
private readonly CAPTION_THROTTLE_MS = 200; // 200ms更新一次
on('captionResult', (result) => {
let now = Date.now();
if (now - this.lastCaptionUpdate > this.CAPTION_THROTTLE_MS) {
this.captionText = result.text;
this.lastCaptionUpdate = now;
}
});
- 优先使用简体中文语言配置:如果识别多种语言,
CaptionLanguage.CHINESE_SIMPLIFIED的离线包体积最小,加载最快。英文或混合语言需要额外下载语言包,首次启动会有明显延迟。
Demo 入口
// index.ets
import { VideoPlayerWithCaptions } from './VideoPlayerWithCaptions';
@Entry
@Component
struct Index {
build() {
VideoPlayerWithCaptions()
}
}
FAQ
Q:为什么真机测试正常,但模拟器上无法启动字幕?
A:模拟器可能没有真正的麦克风硬件,或者虚拟麦克风驱动不兼容。AI字幕控件依赖底层音频驱动,建议始终在真机上测试。如果必须用模拟器,可以尝试在设置里开启“虚拟音频输入”。
Q:为什么第一次打开应用时蒙层授权成功,第二次却弹窗失败?
A:这是 HarmonyOS 权限管理的一个行为:如果用户第一次授权后没有在系统中手动关闭权限,第二次启动不会再次弹窗,而是自动授权。但如果用户第一次拒绝并勾选“不再提示”,第二次就直接返回失败。建议在 requestPermissionsFromUser 失败后,引导用户去系统设置里手动开启。
Q:AI字幕控件支持在后台运行吗?
A:不支持。AICaption 依赖前台页面 UI(必须有一个可见的组件),一旦页面被切入后台或销毁,识别立即停止。如果需要后台持续识别,需要使用底层语音识别 API 并自行管理服务进程。
示例代码地址:项目地址
更多推荐



所有评论(0)