前言

录音转写和文字朗读进入同一个应用以后,页面需要处理的事情会发生变化。用户可能刚开始识别就返回首页,也可能在等待引擎创建时再次点击按钮。声音服务各自完成自己的工作,界面却只有一处处理状态和结果区域。如果每个按钮独立更新这些位置,旧操作的完成消息就可能覆盖正在执行的新操作。

会议笔记应用还需要分清声音从哪里来、文字要去哪里。麦克风识别接收正在发生的声音,文件转写读取已经存在的音频,文字播报则接收一段明确文本。这些输入可以在同一产品里出现,但它们的权限、结束条件和资源不同。应用需要共用入口与显示规则,同时让每个服务继续执行自己的调用顺序。

首页管理各项语音功能,集成页面则提供麦克风识别和文字播报入口。页面需要启动操作、展示返回结果,并在停止或切换时释放资源。识别文字和播报文本目前仍是独立输入;页面没有自动生成会议纪要,也没有把内容写入业务数据库。

语音 AI 工程集成封面

一、页面共用操作入口,服务保留自己的类型

打开应用以后,首页列出实时识别、文件转写、长语音、热词、文字播报、长文本朗读和数据流播放。每项入口都进入当前页面中的对应功能区,输入、操作按钮和结果说明随选择变化。功能集成入口同时提供开始麦克风识别与开始文字播报,便于从同一界面检查两种服务的切换。

识别过程集中在 SpeechSession,文字输出集中在 SpeechOutput,朗读面板由 ReadingPanel 处理。页面持有这三个对象,并把返回事件交给同一个接收方法;各服务内部仍使用各自的参数和接口处理声音。这样按钮只需要发起对应操作,状态更新也有统一入口。

下面三行就是页面建立服务对象的位置。每个服务都通过回调交回事件类别与内容,因此输入区、处理状态和结果区域可以继续使用同一组页面状态。

  private session: SpeechSession = new SpeechSession((kind, value) => this.receive(kind, value));
  private output: SpeechOutput = new SpeechOutput((kind, value) => this.receive(kind, value));
  private reader: ReadingPanel = new ReadingPanel((kind, value) => this.receive(kind, value));

这三个类是应用自己的封装:识别服务调用语音识别接口,输出服务持有合成引擎和音频播放器,朗读服务使用系统控件。它们各自处理对应的开始、停止和释放步骤,页面通过回调接收结果。

服务把事件类别和内容交给页面,页面根据类别分别显示识别文字、音频格式和错误信息。当前事件内容采用字符串,页面直接展示返回内容。

页面入口与各语音服务的职责

首页决定进入哪个操作,页面负责状态与显示,各个服务继续管理属于自己的会话和资源。识别、合成和朗读控件的错误信息也分别返回页面。

二、忙碌状态要等到真实操作结束

页面共用一处结果区域,因此当前实现采用逐次执行的交互方式。用户发起一项操作时,入口先检查是否忙碌;前一项尚未结束,新的开始操作会显示当前操作尚未结束。这个限制来自页面结构,能够让一份结果对应一个正在执行的输入,避免两个按钮同时争用同一处提示。

开始操作以后,入口清空旧结果和详情,并显示正在初始化。随后才执行具体的异步方法。初始化失败时,页面读取错误消息和错误码,恢复可以开始的状态。当前实现没有在每次方法返回后立即清除忙碌,因为有些方法返回时,声音服务才刚刚开始工作。

  private async perform(action: () => Promise<void>): Promise<void> {
    if (this.busy) { this.status = '当前操作尚未结束'; return; }
    this.busy = true;
    this.result = '';
    this.detail = '';
    this.status = '正在初始化';
    try { await action(); } catch (error) { const failure = error as BusinessError;
      this.receive('error', (failure.code ? failure.code + ' ' : '') + failure.message); this.busy = false; }
  }

麦克风启动成功以后,还要继续等待用户说话;文字播报提交以后,也需要等待声音处理结束。页面因此在识别完成、错误、取消、播放器完成或合成文件保存等具体事件上解除忙碌。直接播报还要区分合成完成与播放完成,只有播放完成的类型才结束这次播报操作。若在引擎初始化完成时就恢复按钮,用户很容易在同一段声音尚未结束时再次发起请求。

朗读面板还有暂停、继续和切换内容等操作,因此页面允许这些控制动作在面板启动后继续执行。朗读服务保存初始化状态,并让开始与释放按顺序执行。页面的忙碌标记只限制普通开始入口。

结果区域也有明确的替换时机:开始新操作时,页面清空上一次结果,随后显示本次识别文字或处理消息。这份状态用于当前页面展示,没有保存历史转写,也没有自动作为播报输入。

三、用请求身份处理较晚返回的结果

按钮被限制为逐次执行,仍然不能消除所有时间差。用户可以在引擎初始化过程中点击停止或返回,异步创建却可能在稍后完成。页面已经换到其他功能时,旧服务必须知道自己是否还有继续工作的资格。当前识别与合成服务分别用会话标识和请求标识处理这个问题。

识别开始时保存本次会话标识。麦克风授权返回以后,服务会检查会话是否仍然有效;引擎创建完成以后,再检查一次。如果用户已经取消,服务就关闭刚创建的引擎,不再启动识别。下面的代码处理的正是创建期间离开页面的情况。

      const created = await speechRecognizer.createEngine({ language: 'zh-CN', online: 1, extraParams: extras });
      if (id !== this.session || !this.active) { created.shutdown(); return; }
      this.engine = created;

识别回调还会核对返回的会话标识。当前子句的临时文字用于刷新显示,子句成为最终结果以后才追加到已确认文字中。会话被取消后,标识先被清空,旧回调即使到达也不会继续改写结果。文件输入循环同时检查会话是否仍然有效,因此退出操作也会停止继续向引擎发送后面的音频片段。

合成服务采用自己的请求标识,开始新请求时会先清理旧合成状态。创建引擎和查询音色都需要等待,每次等待结束后继续确认请求归属。数据、完成和错误回调也先比较标识,避免上一次合成的完成消息解除下一次任务的忙碌状态。

文件播放器另外保存一个播放编号。用户停止时,编号发生变化,写入循环就不会继续送入剩余文件内容;旧播放器仍然沿结束路径关闭文件和释放自身。朗读控件则使用串行等待,让新的开始排在尚未完成的释放之后。

四、返回首页时分别结束资源

页面只改变标题和按钮,还不足以完成一次功能切换。旧识别可能仍在采集,旧合成可能仍在播放,系统朗读面板也可能继续持有内容。当前切换入口因此先处理旧服务,再清空结果和输入说明。新的页面不继承旧文件信息,用户也就不会在朗读页看到另一项操作留下的音频参数。

  private switchTo(id: number): void {
    this.captionGeneration++; this.captionShown = false;
    this.session.cancel(); this.output.stop();
    this.reader.release().catch((error: Error) => this.receive('error', error.message));
    this.article = id; this.status = '等待开始'; this.result = ''; this.detail = ''; this.inputInfo = ''; this.busy = false;
  }

识别服务会先使会话失效,然后取消仍在处理的识别,并在结束路径关闭引擎。合成服务停止当前请求并释放合成引擎;如果文件播放器正在使用音频,它也会收到停止要求。朗读服务需要等待停止,注销对应监听,再释放控件资源。

应用还接收外部启动参数,用于进入指定功能。当前入口把目标编号放到共享状态,再由页面观察变化并进入同一个切换方法。这样从首页选择另一项功能,或者通过启动参数切换到不同编号时,都会进入清理与重置。直接修改当前显示编号会绕过这一步,旧输入和服务就可能跟着用户进入新的功能区域。

页面离开时也会取消识别、停止输出并请求释放朗读资源。当前朗读参数关闭了后台持续播放。

权限同样由实际输入决定。系统麦克风识别分支才请求麦克风权限,读取本地音频文件和合成固定文字不会顺带申请录音。朗读控件的上下文与窗口准备保留在应用入口中。用户拒绝录音时,页面应该停在能够说明原因的位置,随后仍可使用不需要麦克风的文字播报;统一页面不要求所有能力先获得同一份授权。

五、在同一页面核对识别与输出

进入语音功能集成以后,页面提供麦克风识别、结束输入、文字播报和停止并释放。开始麦克风识别时,系统负责采集声音,页面接收返回文字。当前使用固定普通话声音进行核对,声音通过手机麦克风进入识别引擎。

nova 14 中的统一功能入口与识别操作

识别完成以后,可以检查最后一句标记与完成状态,再发起文字播报。播报按钮读取固定中文资源,创建独立合成请求,并由播放回调更新状态。播报文字没有自动取自识别结果,两次操作分别使用各自的输入。

nova 14 集成页面返回的真实识别文字

2026 年 10 月 22 日,nova 14 在 API 26 Release 环境中完成了这组集成操作。固定普通话声音经电脑播放后由手机麦克风采集,页面收到最终文字和识别完成回调;结果中十点被识别成十年,人物名称也存在偏差。随后,同一个页面提交固定中文播报,先收到合成完成,再收到播放完成。

识别尚未结束时再次点击开始,页面应继续显示原任务;收到完成消息以后,新的普通操作才可以开始。停止和返回时,可以观察采集或播放状态是否结束,以及重新进入后能否发起新的操作。

总结

Core Speech Kit 提供语音识别和文本转语音,Speech Kit 的朗读控件提供文章播放面板。集成页面集中管理入口和状态,识别、语音输出与朗读面板仍由各自的服务完成。页面中的麦克风识别和固定文本播报使用独立输入,分别返回各自的结果。

页面忙碌状态控制新的普通操作能否开始,会话标识和请求标识则用来判断迟到的回调是否仍属于当前任务。切换或离开时,页面分别通知识别、合成和朗读服务处理资源。这样,同一应用可以组织多种语音入口,同时保留每种能力的调用方式和结束条件。

核心关键代码

import { BusinessError } from '@kit.BasicServicesKit';
import { util } from '@kit.ArkTS';
import { common } from '@kit.AbilityKit';
import { fileIo } from '@kit.CoreFileKit';
import { AICaptionController, AICaptionComponent, AICaptionOptions } from '@kit.SpeechKit';
import { SpeechSession } from '../services/SpeechSession';
import { SpeechOutput } from '../services/SpeechOutput';
import { ReadingPanel } from '../services/ReadingPanel';

@Entry
@Component
struct Index {
  // 外部请求的功能编号变化时,通过统一入口切换页面。
  @StorageLink('requestedArticle') @Watch('onArticleRequest') requestedArticle: number = 0;
  @State article: number = 0;
  private onArticleRequest(): void { this.switchTo(this.requestedArticle); }
  @State status: string = '请选择功能';
  @State result: string = '';
  @State detail: string = '';
  @State busy: boolean = false;
  @State inputInfo: string = '';
  @State sourceText: string = '';
  private async loadSource(): Promise<void> {
    this.sourceText = await this.text('tts.txt');
  }
  aboutToAppear(): void { this.switchTo(this.requestedArticle); this.loadSource().catch((error: Error) => this.receive('error', error.message)); }
  // 各语音服务独立管理资源,回调统一交给页面处理。
  private session: SpeechSession = new SpeechSession((kind, value) => this.receive(kind, value));
  private output: SpeechOutput = new SpeechOutput((kind, value) => this.receive(kind, value));
  private reader: ReadingPanel = new ReadingPanel((kind, value) => this.receive(kind, value));
  private titles: string[] = ['语音 AI 实验台', '实时语音识别', 'PCM 音频转写', '长语音识别',
    '专业词与热词对照', '文本转语音', '长文本朗读', '合成数据流与音频播放', '语音功能集成'];
  private trace: string = '';
  @State captionSupported: boolean = false;
  @State captionShown: boolean = false;
  @State captionReady: boolean = false;
  private captionGeneration: number = 0;
  private captionController: AICaptionController = new AICaptionController();
  private captionOptions: AICaptionOptions = {
    sourceLanguage: 'zh', targetLanguage: 'zh', initialOpacity: 1,
    onPrepared: () => { this.captionReady = true; this.receive('captionPrepared', '字幕组件初始化完成'); },
    onError: (error: BusinessError) => { this.receive('error', error.code + ' ' + error.message); }
  };
  // 字幕组件支持且准备完成后,才按组件要求的格式输入 PCM。
  private async feedCaption(): Promise<void> {
    if (!this.captionSupported || !this.captionReady) { throw new Error('设备未支持或字幕组件尚未初始化'); }
    const generation = ++this.captionGeneration;
    const format = this.captionController.getAudioInfo();
    this.receive('captionFormat', JSON.stringify(format));
    if (format.audioType !== 'pcm' || format.sampleRate !== 16000 || format.soundChannel !== 1 || format.sampleBit !== 16) {
      throw new Error('组件返回格式与固定字幕素材不一致,停止输入');
    }
    const data = await this.context().resourceManager.getRawFileContent('caption.pcm');
    for (let offset = 0; offset < data.length && generation === this.captionGeneration && this.captionShown; offset += 1280) {
      const packet = new Uint8Array(1280);
      packet.set(data.subarray(offset, Math.min(offset + 1280, data.length)));
      this.captionController.writeAudio({ data: packet });
      await new Promise<void>((resolve) => setTimeout(resolve, 40));
    }
    this.receive('captionInputEnd', '已结束输入,字幕结果需要在真实组件中核对');
    this.busy = false;
  }


  private context(): common.UIAbilityContext { return this.getUIContext().getHostContext() as common.UIAbilityContext; }
  // 根据事件类型更新输入信息、识别文字、进度和状态。
  private receive(kind: string, value: string): void {
    const line = Date.now() + ' | ' + this.article + ' | ' + kind + ' | ' + value;
    console.info('SpeechAILab ' + line);
    this.trace += line + '\n';
    const file = fileIo.openSync(this.context().filesDir + '/speech-trace.log',
      fileIo.OpenMode.CREATE | fileIo.OpenMode.WRITE_ONLY | fileIo.OpenMode.APPEND);
    try { fileIo.writeSync(file.fd, line + '\n'); } finally { fileIo.closeSync(file); }
    if (kind === 'input' || kind === 'ttsStart') { this.inputInfo = value; }
    if (kind === 'result') { this.result = value; }
    else if (kind === 'progress') { this.detail = '输入进度:' + value; }
    else if (kind === 'rawResult') {
      const final = value.includes('\"isFinal\":true');
      const last = value.includes('\"isLast\":true');
      this.detail = (final ? '当前子句:最终文字' : '当前子句:临时文字') + (last ? ' · 最后一句' : '');
    }
    else if (kind === 'ttsData') { this.detail = '音频分片:' + value; }
    else { this.status = kind + ':' + value; }
    // 不同任务在完成、出错或取消后解除忙碌;播报等待 type 1。
    if (kind === 'complete' || kind === 'error' || kind === 'cancel' || kind === 'rendererComplete') { this.busy = false; }
    if (kind === 'ttsSaved') { this.result = value; this.busy = false; }
    if (kind === 'ttsComplete' && value.includes('"type":1')) { this.busy = false; }
  }
  // 普通开始操作共用忙碌检查,避免新任务覆盖当前结果。
  private async perform(action: () => Promise<void>): Promise<void> {
    if (this.busy) { this.status = '当前操作尚未结束'; return; }
    this.busy = true;
    this.result = '';
    this.detail = '';
    this.status = '正在初始化';
    try { await action(); } catch (error) { const failure = error as BusinessError;
      this.receive('error', (failure.code ? failure.code + ' ' : '') + failure.message); this.busy = false; }
  }
  private async text(name: string): Promise<string> {
    const content = await this.context().resourceManager.getRawFileContent(name);
    // 素材为UTF-8,通过系统解码器读取。
    const decoder = new util.TextDecoder('utf-8');
    return decoder.decodeToString(content);
  }
  // 切换功能前使字幕输入失效,并停止或释放各语音服务。
  private switchTo(id: number): void {
    this.captionGeneration++; this.captionShown = false;
    this.session.cancel(); this.output.stop();
    this.reader.release().catch((error: Error) => this.receive('error', error.message));
    this.article = id; this.status = '等待开始'; this.result = ''; this.detail = ''; this.inputInfo = ''; this.busy = false;
  }
  aboutToDisappear(): void { this.session.cancel(); this.output.stop(); this.reader.release(); }

  @Builder
  action(label: string, task: () => void) {
    Button(label).height(44).fontSize(15).backgroundColor('#2966CF').fontColor(Color.White)
      .margin({ right: 8, bottom: 10 }).onClick(task)
  }

  build() {
    Column() {
      Row() {
        if (this.article !== 0) { Button('返回').onClick(() => this.switchTo(0)).backgroundColor('#E7EDFA').fontColor('#285294').margin({ right: 12 }) }
        Text('语音 AI 实验台').fontSize(21).fontWeight(FontWeight.Bold).fontColor('#162D53')
      }.width('100%').padding({ top: 12, bottom: 16 })
      Scroll() {
        Column({ space: 16 }) {
          if (this.article === 0) {
            Text('把声音和文字连接起来').fontSize(28).fontWeight(FontWeight.Bold).fontColor('#172E54').width('100%')
            Text('中文输入 · 本地识别 · 朗读与播放').fontSize(15).fontColor('#667790').width('100%')
            this.action('检测 AI 字幕支持', () => {
              this.captionSupported = this.captionController.isCapabilitySupported();
              this.receive('captionCapability', this.captionSupported.toString());
              if (this.captionSupported) { this.captionShown = true; }
            })
            Text(this.status).fontSize(14).fontColor('#52709B').width('100%')
            if (this.captionSupported) {
              this.action('输入字幕 PCM', () => this.perform(() => this.feedCaption()))
              this.action('隐藏字幕', () => { this.captionGeneration++; this.captionShown = false; })
              AICaptionComponent({ isShown: this.captionShown, controller: this.captionController, options: this.captionOptions })
                .width('100%').height(160)
            }
            ForEach([1, 2, 3, 4, 5, 6, 7, 8], (id: number) => {
              Row() {
                Text(id.toString().padStart(2, '0')).fontSize(24).fontColor('#6D8AC9').width(50)
                Text(this.titles[id]).fontSize(19).fontColor('#19345B').layoutWeight(1)
                Text('›').fontSize(25).fontColor('#6D8AC9')
              }.width('100%').padding(19).backgroundColor(Color.White).borderRadius(18).onClick(() => this.switchTo(id))
            })
          } else {
            Text(this.titles[this.article]).fontSize(27).fontWeight(FontWeight.Bold).fontColor('#162D53').width('100%')
            Column({ space: 9 }) {
              Text('输入与参数').fontSize(14).fontColor('#70809A').width('100%')
              Text(this.article <= 4 ? (this.article === 1 ? '麦克风实时输入' : (this.article === 3 ? '虚构会议录音 meeting.pcm' : (this.article === 4 ? '固定专业词音频 terms.pcm' : '固定普通话音频 file.pcm'))) :
                this.article === 6 ? '两篇中文内容 · 朗读列表与系统面板' : '固定中文文字 · 离线中文音色 13')
                .fontSize(18).fontColor('#223D63').width('100%')
              if (this.article === 4) { Text('热词:会议随记 · 引擎或会话配置').fontSize(14).fontColor('#617998').width('100%') }
              Text(this.article <= 4 ? 'PCM · 16000Hz · 单声道 · 16位' : this.article === 6 ? 'TextReader · 前台内容朗读' : 'Core Speech Kit · 中文离线合成')
                .fontSize(14).fontColor('#73829A').width('100%')
              if (this.inputInfo) { Text(this.inputInfo).fontSize(13).fontColor('#617998').width('100%') }
              if (this.article === 5 || this.article === 7 || this.article === 8) {
                Text(this.sourceText).fontSize(15).lineHeight(24).fontColor('#466183').width('100%').maxLines(5)
              }
            }.width('100%').padding(20).backgroundColor(Color.White).borderRadius(18)
            Flex({ wrap: FlexWrap.Wrap }) {
              // 实时识别与集成页面共用麦克风入口。
              if (this.article === 1 || this.article === 8) {
                this.action('开始麦克风识别', () => this.perform(() => this.session.start(this.context(), '', false)))
                this.action('结束输入', () => this.session.finish())
              }
              if (this.article === 2 || this.article === 3) {
                this.action('开始文件转写', () => this.perform(() => this.session.start(this.context(), this.article === 3 ? 'meeting.pcm' : 'file.pcm', this.article === 3)))
              }
              if (this.article === 4) {
                this.action('无热词识别', () => this.perform(() => this.session.start(this.context(), 'terms.pcm', false)))
                this.action('引擎热词识别', () => this.perform(() => this.session.start(this.context(), 'terms.pcm', false, true)))
                this.action('会话热词识别', () => this.perform(() => this.session.start(this.context(), 'terms.pcm', false, false, true)))
              }
              // 播报读取固定文字,与识别返回的文字相互独立。
              if (this.article === 5 || this.article === 8) {
                this.action('开始文字播报', () => this.perform(async () => this.output.speak(this.context(), await this.text('tts.txt'), false)))
              }
              if (this.article === 7) {
                this.action('合成并保存 PCM', () => this.perform(async () => this.output.speak(this.context(), await this.text('tts.txt'), true)))
                this.action('播放合成文件', () => this.perform(() => this.output.playSaved()))
              }
              // 朗读面板使用两篇本地正文,控制操作交给朗读服务。
              if (this.article === 6) {
                this.action('打开朗读面板', () => this.perform(async () => {
                  await this.reader.start(this.context(), await this.text('reader-1.txt'), await this.text('reader-2.txt')); this.busy = false;
                }))
                this.action('暂停朗读', () => this.reader.pause())
                this.action('继续朗读', () => this.reader.resume())
                this.action('下一篇内容', () => this.reader.next())
                this.action('显示面板', () => this.reader.show())
              }
              this.action('停止并释放', () => { this.session.cancel(); this.output.stop(); this.reader.release(); this.busy = false; })
            }.width('100%')
            Column({ space: 10 }) {
              Text('处理状态').fontSize(14).fontColor('#70809A').width('100%')
              Text(this.status).fontSize(16).fontColor('#2659A7').width('100%')
              if (this.detail) { Text(this.detail).fontSize(13).fontColor('#718197').width('100%') }
            }.padding(20).width('100%').backgroundColor('#EAF1FE').borderRadius(18)
            Column({ space: 12 }) {
              Text(this.article <= 4 || this.article === 8 ? '识别文字与处理结果' : '返回结果').fontSize(14).fontColor('#70809A').width('100%')
              Text(this.result || '操作完成后,真实返回结果会显示在这里。').fontSize(18).lineHeight(29).fontColor('#223B60').width('100%')
            }.padding(20).width('100%').backgroundColor(Color.White).borderRadius(18)
          }
        }.width('100%').padding({ bottom: 32 })
      }.layoutWeight(1).scrollBar(BarState.Off)
    }.width('100%').height('100%').padding({ left: 20, right: 20, top: 34, bottom: 20 }).backgroundColor('#F3F6FC')
  }
}
Logo

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

更多推荐