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 并自行管理服务进程。

示例代码地址:项目地址

相关推荐
千逐681 小时前
Uniapp 鸿蒙实战之 AtomGit APP - 首页设计与仓库列表实现
华为·uni-app·harmonyos
binbin_522 小时前
Flutter 调用鸿蒙原生组件:MethodChannel 与 PlatformView 的选择和落地
开发语言·深度学习·flutter·harmonyos
●VON2 小时前
HarmonyKit | 鸿蒙新特性规范:10 个工具页 UI 一致性设计系统
ui·华为·harmonyos·鸿蒙·新特性
木木子2213 小时前
# 待办事项应用深度解析:ForEach 列表渲染与 CRUD 操作实战
windows·华为·harmonyos
2501_9437823515 小时前
【共创季稿事节】摇骰子:用 ArkTS 实现随机动画与交互反馈
运维·nginx·交互·harmonyos·鸿蒙·鸿蒙系统
zjxcq52015 小时前
【共创季稿事节】鸿蒙原生ArkTS布局之道——layoutWeight权重分配机制深度解析
华为·harmonyos
2501_9437823518 小时前
【共创季稿事节】猜数字游戏:二分法思维与交互式反馈
前端·游戏·microsoft·harmonyos·鸿蒙·鸿蒙系统
想你依然心痛18 小时前
AtomCode 在 HarmonyOS 开发环境中的表现测评
跨平台·harmonyos·arkts·信创·国产系统
2501_9437823519 小时前
【共创季稿事节】 倒计时器:时分秒选择器与定时器的协同工作
前端·华为·harmonyos·鸿蒙·鸿蒙系统