前言

录音页面增加文字显示以后,用户可以边说边核对内容,发现漏词时及时补充。然而,麦克风正在工作与页面已经得到可保存的文字,属于两个不同的状态。声音需要经过采集和识别,识别引擎也可能在后续声音到达以后修订前面的文字。如果应用把每一次回调都追加到正文里,同一句话就会反复出现;如果页面始终等待最后一次结果,用户又无法知道刚才的声音有没有进入识别过程。

Core Speech Kit 提供中文离线语音识别。应用负责申请录音权限、创建引擎、维护会话和展示文字,系统可以在实时录音模式下采集麦克风声音。这种分工让页面不用另外建立录音器,但权限、启动和结束仍然需要按顺序完成。按钮响应以后立即显示识别中,会把尚未成功创建引擎的阶段也算进去,发生错误时就很难判断问题停在哪里。

页面提供开始麦克风识别、结束输入和停止并释放三个操作,并在下方显示识别文字。2026 年 10 月 22 日,工程在 API 26 Release 上完成构建,并在 HUAWEI nova 14 标准版运行。测试时,Mac 扬声器播放了一段 27.843 秒的虚构中文材料,手机通过麦克风采集声音;识别引擎没有通过音频流接口读取该文件。要让页面接收这段声音,开始按钮首先需要取得录音权限。

实时语音识别与麦克风输入封面

一、让开始按钮先取得录音条件

打开工程并安装应用以后,从首页进入实时语音识别。页面上方说明输入来自麦克风,下方保留处理状态和文字区域。第一次点击开始麦克风识别时,应用请求 ohos.permission.MICROPHONE;系统已经保存授权的设备,也可能直接返回授权结果。应用需要检查返回值,确认授权成功以后才创建引擎,不能把调用权限请求的方法本身当作已经得到授权。

权限需要同时出现在模块声明和运行时请求中。模块的 requestPermissions 说明使用原因与前台场景,运行时请求则让用户决定当前应用是否可以录音。两处承担不同职责,缺少其中一处都会影响采集。文件识别页面没有采集麦克风,因此同一工程里的文件入口跳过这段请求;这种区分能够避免用户只是转写已有文件,页面却突然要求使用麦克风。

下面这段代码位于识别服务的启动方法中。空文件名代表实时麦克风入口,id 保存这次操作的会话身份,context 来自当前页面所属的应用上下文。

      if (filename === '') {
        const auth = await abilityAccessCtrl.createAtManager().requestPermissionsFromUser(context,
          ['ohos.permission.MICROPHONE']);
        if (id !== this.session || !this.active) { return; }
        this.sink('permission', JSON.stringify(auth.authResults));
        if (auth.authResults.some((value: number) => value !== 0)) { throw new Error('麦克风权限未获得'); }
      }

权限弹窗可能停留一段时间。用户在等待期间可以离开页面,原来的授权请求却仍然会返回,所以代码在授权之后重新检查会话身份和活动状态。已经取消的操作直接结束,不再继续创建识别引擎。

如果权限没有获得,服务会抛出麦克风权限未获得的错误,由页面显示消息。错误对象需要读取 message,直接序列化普通异常对象可能只得到空对象。

nova 14麦克风识别入口与处理状态

这个入口需要观察的是输入来源和状态区域。应用没有要求选择音频文件,开始以后由系统采集手机周围的声音。授权完成、引擎创建成功并收到启动回调后,页面开始等待识别文字。

手机麦克风采集到的是扬声器声音与周围环境的混合。复现时可以先让手机靠近可正常播放的声音来源,再点击开始,等启动成功以后播放固定材料。设备媒体音量、两台设备之间的距离和室内噪声都可能改变输入,因此保留原始材料能够帮助分清内容变化与识别变化。

二、把一次录音绑定到一条识别会话

语音引擎创建时,应用选择 zh-CN 和离线模式,online: 1 表示离线,不能按照字段名称猜成联网。创建参数里的 recognizerMode 选择 short,启动参数里的 recognitionMode 选择 0。前一个字段决定短语音处理模式,后一个字段决定声音由系统实时录音取得。把两项都叫识别模式容易在排错时混淆,因此代码和页面说明需要保留它们各自的职责。

引擎创建完成以后,应用先调用 setListener 注册监听,再调用 startListening。监听提前安装,能够接住会话开始、语音事件、文字结果和完成消息。如果先启动再注册,页面就可能缺少启动阶段的观察入口。当前实现还等待 onStart,没有在创建引擎返回以后立刻认为麦克风会话已经准备好。

每次启动使用新的 sessionId,格式为英文前缀、下划线与时间值。回调返回会话标识,应用先核对身份,再更新页面。用户停止后重新开始时,这项检查能把旧任务迟到的消息挡在新的结果之外。

麦克风权限、识别会话与页面结果流程

图中的声音路径直接从系统录音进入识别引擎,应用接收的是状态与文字回调。当前实时路径没有调用 writeAudio;这个接口用于应用主动提供音频数据。

短语音会话把最长输入设为60000毫秒,前端等待设为10000毫秒,尾部静音设为3000毫秒。前端等待限制开始后没有说话的时间,尾部静音帮助判断声音已经停下,两者不能当作识别响应速度。当前采用较宽的等待时间,是为了给声学输入留出准备空间。

页面还需要区分初始化与正在处理。初始化可能包含授权和创建引擎,正在处理则意味着会话已经开始接收输入。语音事件会提供开始说话和停止说话的信息,文字回调继续给出内容。当前页面把这些实际消息显示在状态区域,排错时能够沿着最后出现的位置继续检查,避免所有异常都只剩一句识别失败。

启动方法返回与整次识别结束还需要分别处理。麦克风路径在会话准备好以后就从启动方法返回,后面的讲话和识别通过监听继续进行,所以页面不能只在等待这个方法结束以后就解除忙碌状态。当前页面在用户点击开始时锁住新的启动操作,随后由完成、错误或取消消息恢复。这样即使用户在讲话过程中再次点击开始,也会先得到当前操作尚未结束的提示,而不会同时创建另一条会话。

等待启动的循环最多检查一百五十次,每次间隔二十毫秒。这段约三秒的等待是页面自己的超时设置;超过时间后,服务会报告没有等到启动结果并释放引擎。

三、让临时文字替换,让最终子句保留

声音进入以后,同一句话可能触发多次 onResult。临时结果会随着后续声音变化,最终结果则表示当前子句已经确定。页面需要保留已经确定的部分,同时让正在识别的部分更新。把临时结果也永久追加,会出现这、这是、这是一次这样的重复内容;每次都覆盖全部正文,又会丢掉之前已经结束的子句。

当前服务使用 finalText 保存已经确定的文字。收到最终子句时,服务把它追加进去;收到临时结果时,服务只在显示值中接上这一段,不改动已保存的终稿。对应代码直接位于监听对象中,显示层只接收整理后的结果。

        onResult: (sid: string, result: speechRecognizer.SpeechRecognitionResult) => {
          if (sid !== this.session) { return; }
          this.sink('rawResult', JSON.stringify(result));
          if (result.isFinal) { this.finalText += result.result; }
          this.sink('result', this.finalText + (result.isFinal ? '' : result.result));
        },

isFinal 表示当前子句已经定稿,isLast 表示它是最后一句。两个字段经常会在短录音结束时一起出现,但应用不能因此认定它们始终相同。用户停顿较多或改用长语音以后,前面的最终子句仍可能后接新的文字。整个会话何时结束,页面继续等待 onComplete,不会因为任意一条最终子句就允许再次启动。

这次固定材料描述三位成员的工作安排。手机先返回多条临时文字,随后返回一条 isFinal: true、isLast: true 的结果,并触发完成回调。输入中的林舟被识别成林州,许岚成为许兰,陈禾也被识别成了全和;页面显示的是这些实际返回的文字。

nova 14麦克风识别实际文字结果

截图展示了最终文字,回调记录还保留了此前的临时更新。参考材料中的下午三点和明天上午十点在结果中采用了数字写法,与姓名识别错误属于不同类型的变化。当前材料未用于统一字错率评测。

收到结果后,页面把文字写入可观察的状态变量,由界面随状态重新绘制。识别服务不直接操作文本组件,错误消息和识别文字因此可以进入不同区域,处理提示也不会混进正文。

四、让结束输入和取消各自完成清理

用户说完以后,结束输入调用 finish,让当前识别过程收尾并继续等待结果;停止并释放则取消当前任务并释放引擎。前一种操作希望保留本次转写,后一种操作允许用户放弃处理。页面需要把两个入口说清楚,否则用户只是想结束讲话,却可能主动取消了尚未返回的文字。

当前服务在完成回调中恢复空闲,并调用 shutdown 释放引擎。启动失败与识别错误也进入释放路径,避免页面显示失败以后仍持有无用资源。每次任务结束就释放引擎,是当前页面采用的处理方式。

取消路径先保存原引擎与会话标识,再把当前活动状态、就绪状态和身份清空。这样旧回调即使随后到达,也无法继续更新当前页面。真正调用系统取消和释放时,代码使用之前保存的局部对象,确保清理的是原来的引擎。

  cancel(): void {
    const engine = this.engine;
    const id = this.session;
    const wasActive = this.active;
    this.active = false;
    this.ready = false;
    this.session = '';
    this.engine = undefined;
    try { if (wasActive && id !== '') { engine?.cancel(id); } }
    finally { engine?.shutdown(); }
    this.sink('cancel', '已取消并释放识别引擎');
  }

finally 保证取消过程出现异常时仍然尝试释放资源。清理还需要覆盖初始化期间,因为权限或引擎创建都可能晚于用户的返回动作完成。创建成功以后再核对会话身份,能够把这种迟到的引擎及时关闭;只在页面消失时检查当时是否有引擎,还会漏掉尚未返回的创建请求。

识别没有按预期进行时,可以依次检查权限结果、引擎创建结果、启动回调、语音事件和文字结果。确定最后成功的一步以后,再区分问题发生在声音采集、回调接收还是页面更新。

总结

Core Speech Kit 的实时识别模式让系统采集麦克风声音,并通过会话回调返回识别文字。应用先取得录音权限、创建引擎,再根据启动、语音事件和完成消息更新页面;会话标识用来区分多次操作,防止旧回调覆盖新的结果。

文字显示分为两部分:已经定稿的子句持续保留,临时文字随识别过程更新。结束输入后,页面继续等待最终结果和完成回调;用户取消时,服务使当前会话失效并释放引擎。

核心关键代码

import { speechRecognizer } from '@kit.CoreSpeechKit';
import { common, abilityAccessCtrl } from '@kit.AbilityKit';
import { BusinessError } from '@kit.BasicServicesKit';
interface RecognizerOption { enablePartialResult: boolean; }

export type SpeechSink = (kind: string, value: string) => void;

export class SpeechSession {
  private engine: speechRecognizer.SpeechRecognitionEngine | undefined;
  private session: string = '';
  private active: boolean = false;
  private ready: boolean = false;
  private finalText: string = '';
  private sink: SpeechSink;
  constructor(sink: SpeechSink) { this.sink = sink; }

  async start(context: common.UIAbilityContext, filename: string, longMode: boolean,
    engineHotwords: boolean = false, sessionHotwords: boolean = false): Promise<void> {
    // 一次只运行一个会话,新的标识用于过滤旧操作的回调。
    if (this.active) { throw new Error('识别会话仍在处理,请结束后重试'); }
    this.active = true;
    this.ready = false;
    this.finalText = '';
    this.session = 'speech_' + Date.now();
    const id = this.session;
    try {
      // 空文件名表示使用麦克风,只有这一入口申请录音权限。
      if (filename === '') {
        const auth = await abilityAccessCtrl.createAtManager().requestPermissionsFromUser(context,
          ['ohos.permission.MICROPHONE']);
        if (id !== this.session || !this.active) { return; }
        this.sink('permission', JSON.stringify(auth.authResults));
        if (auth.authResults.some((value: number) => value !== 0)) { throw new Error('麦克风权限未获得'); }
      }
      // 实时入口使用短语音模式,创建参数还保留长语音选项。
      const mode: string = longMode ? 'long' : 'short';
      const extras: Record<string, Object> = {};
      extras['locate'] = 'CN';
      extras['recognizerMode'] = mode;
      if (engineHotwords) { extras['sysGeneralLexicon'] = ['会议随记']; }
      // 引擎创建可能晚于取消操作,返回后先核对当前会话。
      const created = await speechRecognizer.createEngine({ language: 'zh-CN', online: 1, extraParams: extras });
      if (id !== this.session || !this.active) { created.shutdown(); return; }
      this.engine = created;
      this.sink('engine', JSON.stringify(extras));
      this.engine.setListener({
        onStart: (sid: string, message: string) => {
          if (sid !== this.session) { return; }
          this.ready = true;
          this.sink('start', sid + ' ' + message);
        },
        onEvent: (sid: string, code: number, message: string) => {
          if (sid === this.session) { this.sink('event', code + ' ' + message); }
        },
        // 最终子句累计保存,中间结果只用于刷新当前显示。
        onResult: (sid: string, result: speechRecognizer.SpeechRecognitionResult) => {
          if (sid !== this.session) { return; }
          this.sink('rawResult', JSON.stringify(result));
          if (result.isFinal) { this.finalText += result.result; }
          this.sink('result', this.finalText + (result.isFinal ? '' : result.result));
        },
        // 收到会话完成后更新页面状态,并释放识别引擎。
        onComplete: (sid: string, message: string) => {
          if (sid !== this.session) { return; }
          this.active = false;
          this.sink('complete', message);
          this.engine?.shutdown();
          this.engine = undefined;
        },
        onError: (sid: string, code: number, message: string) => {
          if (sid !== this.session) { return; }
          this.active = false;
          this.sink('error', code + ' ' + message);
          this.engine?.shutdown();
          this.engine = undefined;
        }
      });
      const option: RecognizerOption = { enablePartialResult: true };
      // 0 由系统采集麦克风;1 由应用主动写入音频数据。
      const recognitionMode: number = filename === '' ? 0 : 1;
      const maxDuration: number = longMode ? 180000 : 60000;
      const startExtras: Record<string, Object> = {};
      startExtras['recognitionMode'] = recognitionMode;
      if (!longMode) {
        startExtras['vadBegin'] = 10000;
        startExtras['vadEnd'] = 3000;
      }
      startExtras['maxAudioDuration'] = maxDuration;
      startExtras['recognizerOption'] = option;
      if (sessionHotwords) { startExtras['sessionGeneralLexicon'] = ['会议随记']; }
      this.engine.startListening({ sessionId: id,
        audioInfo: { audioType: 'pcm', sampleRate: 16000, soundChannel: 1, sampleBit: 16 },
        extraParams: startExtras });
      this.sink('params', JSON.stringify(startExtras));
      // 等待启动回调后再输入文件,最多等待约三秒。
      for (let count = 0; !this.ready && this.active && count < 150; count++) { await this.delay(20); }
      if (!this.ready) { throw new Error('等待 onStart 超时或识别启动失败'); }
      if (filename !== '') { await this.feed(context, filename, id); }
    } catch (error) {
      if (id !== this.session) { return; }
      this.active = false;
      this.engine?.shutdown();
      this.engine = undefined;
      const failure = error as BusinessError;
      throw new Error((failure.code ? failure.code + ' ' : '') + failure.message);
    }
  }

  private async feed(context: common.UIAbilityContext, filename: string, id: string): Promise<void> {
    const data = await context.resourceManager.getRawFileContent(filename);
    this.sink('input', filename + ' | ' + data.length + ' 字节 | ' + (data.length / 32000).toFixed(2) + ' 秒');
    // 1280字节是40ms;不足一帧的末尾补零,保留有效字节数。
    let sent = 0;
    for (let offset = 0; offset < data.length && this.active && id === this.session; offset += 1280) {
      const packet = new Uint8Array(1280);
      packet.set(data.subarray(offset, Math.min(offset + 1280, data.length)));
      this.engine?.writeAudio(id, packet);
      sent += Math.min(1280, data.length - offset);
      if (offset % 32000 === 0) { this.sink('progress', (sent / 32000).toFixed(1) + ' / ' + (data.length / 32000).toFixed(1) + ' 秒'); }
      await this.delay(40);
    }
    // 文件输入结束后调用 finish,最终文字仍由回调返回。
    if (this.active && id === this.session) {
      this.sink('inputEnd', '有效字节 ' + sent);
      this.engine?.finish(id);
    }
  }

  finish(): void { if (this.active && this.ready) { this.engine?.finish(this.session); } }
  // 先使会话失效,再取消任务;finally 确保继续释放引擎。
  cancel(): void {
    const engine = this.engine;
    const id = this.session;
    const wasActive = this.active;
    this.active = false;
    this.ready = false;
    this.session = '';
    this.engine = undefined;
    try { if (wasActive && id !== '') { engine?.cancel(id); } }
    finally { engine?.shutdown(); }
    this.sink('cancel', '已取消并释放识别引擎');
  }
  private delay(ms: number): Promise<void> { return new Promise<void>((resolve) => setTimeout(resolve, ms)); }
}
Logo

讨论HarmonyOS开发技术,专注于API与组件、DevEco Studio、测试、元服务和应用上架分发等。

更多推荐