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

HarmonyOS 技术精讲:Speech Kit(场景化语音服务)------AI 字幕控件实战教程

开篇:一个容易被忽略的同步问题

HarmonyOS NEXT 开发里,AI 字幕控件这个 API 经常被误用。很多人在文档里看到它能实时语音识别并显示字幕,就觉得直接导入控件、绑定音频流就行了。但真正做起来,你会发现一个很棘手的问题:字幕和媒体播放的同步。

官方示例确实能跑起来,但那是在静态测试环境里。一旦你把 AI 字幕控件集成到视频播放器里,涉及到控制栏操作、音视频时间线对齐、暂停和快进后的字幕位置恢复,情况就完全不一样了。

这篇文章从实战出发,带着你完整做一个带实时字幕的视频播放器。所有代码已测试通过,项目代码文末有链接。

Speech Kit 解决什么问题

AI 字幕控件本质是一个封装好的 UI 组件 + 后台语音识别引擎。它做的事情很简单:

  1. 从麦克风或音频流中捕捉语音数据
  2. 实时识别成文字
  3. 在控件内部以字幕形式展示

和传统方案对比一下:

能力 AI字幕控件(Speech Kit) 传统语音识别SDK自建UI
UI组件 内置,开箱即用 需要自己写Text组件+滚动逻辑
生命周期管理 自动跟随页面 需要手动on/off控制
与媒体同步 提供时间戳回调,需手动处理 完全自己实现
多语言支持 内置,切换方便 需要额外配置识别引擎

所以我的建议很直接:如果只是做"视频播放时显示字幕"这种场景,直接用 AI 字幕控件;如果你的识别结果要喂给其他业务逻辑,那才考虑底层 API。

环境说明

text 复制代码
DevEco Studio 版本:DevEco Studio 6.1.0 及以上
HarmonyOS SDK 版本:HarmonyOS 6.1.0(23) 及以上
目标设备:手机(API 23+)、平板

核心实现:视频播放器+AI 字幕控件

1. 添加依赖和配置权限

ohpm 复制代码
ohpm install @ohos/ime
ohpm install @ohos.multimedia.media

权限配置在 src/main/module.json5 里:

json 复制代码
{
  "module": {
    "requestPermissions": [
      // 必须:录音权限用于语音识别
      {
        "name": "ohos.permission.MICROPHONE",
        "reason": "实时语音识别需要麦克风权限",
        "usedScene": {
          "abilities": ["EntryAbility"]
        }
      },
      // 非必须但建议:网络权限用于云端语言包下载
      {
        "name": "ohos.permission.INTERNET"
      }
    ]
  }
}

这里有个坑:如果只加 INTERNET 不加 MICROPHONE,控件会静默失败 。官方文档只说"需要麦克风权限",但没告诉你失败时没有任何异常抛出来。建议在页面 onPageShow 里主动检测并请求权限。

2. 权限动态申请

typescript 复制代码
// 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

这是核心文件,所有逻辑都在这里:

typescript 复制代码
// 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')}`;
  }
}

这段代码做了几件事:

  1. AI字幕控件初始化:创建实例、配置语言和样式、订阅结果回调
  2. AVPlayer初始化:创建播放器、绑定视频源、监听状态和进度
  3. 字幕同步逻辑 :通过 currentTime 和字幕识别时间戳做对齐
  4. 控制栏:播放/暂停、快进、时间显示

踩坑记录

坑1:页面返回后字幕控件仍然在识别

现象 :当页面 aboutToDisappear 后,麦克风仍然在录制,AI字幕控件还在运行。

原因 :AICaption 实例的 stop() 方法在页面销毁时没有被正确调用。aboutToDisappear 确实执行了,但如果页面被系统回收(比如后台强制杀进程),stop() 可能还没来得及完成。

解决方案 :在 aboutToDisappear 里必须同步等待资源释放,或者使用 try-finally 确保 stop 被调用:

typescript 复制代码
aboutToDisappear() {
  try {
    this.releaseAICaption(); // 先释放AI字幕
  } finally {
    this.releaseAVPlayer(); // 再释放播放器
  }
}

另外,建议在 on('captionError') 回调里检测错误码,如果是 ERR_MICROPHONE_UNAVAILABLE 就自动停止识别并提示用户。

坑2:快进后字幕时间戳对不上

现象:用户快进10秒后,字幕显示的仍然是快进前的识别结果,没有重置。

原因:AI字幕控件内部的时间戳是基于麦克风输入时间计算的,和视频播放器的时间戳是两套体系。快进后,识别引擎不知道视频跳转了,继续从之前的时间点往后识别。

解决方案 :快进前先调用 aiCaption.stop() 停止识别,快进完成后重新 aiCaption.start()。这样识别引擎会从新的时间点重新开始:

typescript 复制代码
seekTo(time: number) {
  if (this.avPlayer) {
    // 暂停字幕
    this.pauseCaption();
    // 设置标记位,告诉播放器seek完成后要重启字幕
    this.needRestartCaption = true;
    this.avPlayer!.seek(time, media.SeekMode.PREVIOUS_SYNC);
  }
}

然后在播放器的 timeUpdate 回调里检查 needRestartCaption:

typescript 复制代码
this.avPlayer!.on('timeUpdate', (time: number) => {
  this.currentTime = time;
  if (this.needRestartCaption) {
    this.startCaption();
    this.needRestartCaption = false;
  }
});

最佳实践

  1. 不要在 build() 里创建 AI 字幕对象 :AICaption 是重量级对象,包含语音引擎实例。每次 build() 执行都会创建新实例,导致内存泄漏。应该在一进入页面时就初始化一次。

  2. 控制字幕更新频率 :captionResult 回调在流式识别模式下非常频繁,可能每秒几十次。不要直接赋值给 @State 变量,否则 UI 会频繁刷新导致卡顿。建议做一个节流:

typescript 复制代码
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;
  }
});
  1. 优先使用简体中文语言配置 :如果识别多种语言,CaptionLanguage.CHINESE_SIMPLIFIED 的离线包体积最小,加载最快。英文或混合语言需要额外下载语言包,首次启动会有明显延迟。

Demo 入口

typescript 复制代码
// 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 并自行管理服务进程。

示例代码地址:项目地址

相关推荐
Georgewu4 小时前
【HarmonyOS 7】 HarmonyOS 应用 Skill创建和编排详解
harmonyos
Georgewu6 小时前
【HarmonyOS 7】 应用 Agent 接入云 A2A 与端 A2A 的实践详解
harmonyos
幻灭行度8 小时前
基于昇腾 910B NPU 部署 SenseVoiceSmall 语音识别服务
人工智能·语音识别
马剑威(威哥爱编程)12 小时前
【共创稿事节】HarmonyOS 7 Agent 开发实战 05:流式输出做打字机对话
华为·harmonyos
O。O蛋黄酥啊12 小时前
实时语音 Agent 开发完全指南:从 ASR/TTS 到 WebRTC/VAD 的链路拆解
大模型·webrtc·语音识别·tts·asr
OH_TPC15 小时前
【鸿蒙优选三方库】@ohos/coap:让鸿蒙设备说上 IoT 的母语
物联网·华为·harmonyos·鸿蒙
程序猿追16 小时前
渐变中间那一格怎么算?用 colormath 做个鸿蒙调色器
华为·harmonyos
程序猿追16 小时前
在 HarmonyOS 6 上做了张 3D 翻转卡片,正面背面来回翻
3d·华为·harmonyos
小π军17 小时前
安卓、iOS、鸿蒙三端的理解
android·ios·harmonyos
m0_7381858218 小时前
Flutter 鸿蒙化实战:volume_controller 适配 OpenHarmony,系统音量控制
flutter·华为·harmonyos·鸿蒙