实现实时语音转文字功能鸿蒙示例代码

本文原创发布在华为开发者社区

介绍

本示例介绍如何使用speechRecognizer实时语言转文字,并且根据光标位置插入文字,以及文本一键清空功能。

实现实时语音转文字功能源码链接

效果预览

使用说明

  1. 点击顶部按钮可切换本人或非本人模拟聊天界面发送消息,本人发送右对齐,非本人发送左对齐。
  2. 点击RichEditor组件唤起输入法,已发送的消息自动避让。
  3. 长按启动实时语音转文字,松开停止语音转文字,根据光标所在位置插入语音识别的文字,点击清空可以清除RichEditor组件中的内容。
  4. 点击发送可以将RichEditor组件中的内容发送出去,可发送文字和图片消息。

实现思路

聊天页面左右布局

通过Flex组件实现左右布局,本人发送时设置方向为FlexDirection.RowReverse,非本人发送时设置方向为FlexDirection.Row。具体实现如下:

typescript 复制代码
List({scroller: this.listScroller}) {
  ForEach(this.data, (item: MsgContent) => {
    ListItem() {
      // 通过isSelf判断是否是本人发送的消息,来决定Flex组件的direction是否需要进行反转。
      Flex({ direction: item.isSelf ? FlexDirection.RowReverse : FlexDirection.Row, space: { main: LengthMetrics.vp(8) } }) {
        Image($r('app.media.avatar'))
          .width(32)
          .aspectRatio(1)

        Text() {
          ...
        }
        ...
      }
      .width('100%')
    }
    .margin(12)
  }, (item: MsgContent, index: number) => `${JSON.stringify(item)}_${JSON.stringify(index)}`)
}

图文混排消息显示

整体使用Text去布局,文字通过内嵌Span组件显示,图片显示通过内嵌ImageSpan组件显示,具体代码如下:

typescript 复制代码
Text() {
  ForEach(item.content, (content: MsgTextImage) => {
    if (content.type === MessageType.Text) {
      Span(content.content)
    }
    if (content.type === MessageType.Image) {
      ImageSpan(content.content)
        .clip(true)
        .objectFit(ImageFit.Contain)    // 图片填充效果设置为Contain,防止图片超出范围
        .size({ width: '20vp', height: '20vp' })
        .margin(2)
        .verticalAlign(ImageSpanAlignment.CENTER)
    }
  }, (item: MsgTextImage, index: number) => `${JSON.stringify(item)}_${JSON.stringify(index)}`)
}
.padding(6)
.borderRadius(4)
.lineSpacing(LengthMetrics.vp(8))   // 设置下每行之间的空格,这样不至于看着很紧凑
.backgroundColor('#ADD8E6')
.constraintSize({ maxWidth: '75%', minHeight: 32 }) // 这里设置下最大宽度和最小高度,消息太长时不要覆盖整个屏幕宽度

点击发送消息时,需对RichEditor组件中的消息转换成其他结构,发送完毕,清理RichEditor输入区域具体代码如下:

typescript 复制代码
private sendMessage() {
  let message: MsgTextImage[] = [];
  richController.getSpans().forEach(span => {
    if ((span as RichEditorTextSpanResult).textStyle !== undefined) {
      message.push({    // 文本消息转换,type为Text,使用Span组件显示
        type: MessageType.Text,
        content: (span as RichEditorTextSpanResult).value
      });
    } else {  
      message.push({    // 图片消息转换,type为Image,使用ImageSpan组件显示
        type: MessageType.Image,
        content: (span as RichEditorImageSpanResult).valueResourceStr
      });
    }
  })
  if (message.length > 0) {
    this.data.push({
      isSelf: this.isSelf,
      content: message
    });
  }
  richController.deleteSpans();
}

实现已发送的消息自动避让

首先在aboutToAppear中设置键盘模式为上抬模式,代码如下:

typescript 复制代码
aboutToAppear(): void {
  this.getUIContext().setKeyboardAvoidMode(KeyboardAvoidMode.RESIZE);
}

但仅仅只设置上抬模式还不够,消息数超过屏幕时,仍然看不到最新发送的消息。因此,在点击RichEditor组件唤起输入法时,需要将消息滚动到底部,代码如下:

typescript 复制代码
RichEditor(this.options)
  .width('100%')
  .borderRadius(4)
  .backgroundColor('#08000000')
  .constraintSize({ maxHeight: 128 })
  .placeholder(this.placeHolder, { fontColor: '#4D242E3E', font: { size: 13 } })
  .layoutWeight(1)
  .clip(true)
  .onDidChange(() => {
    this.isHaveMsg = (richController.getSpans().length !== Number(0))
  })
  .onEditingChange(this.editingChangedCb)   // 监听编辑状态是否发生改变,并执行回调

editingChanged = () => {
  this.curMenuAction = EditMenuAction.None;
  // 需要延迟一会再触发,键盘弹起之后再触发,否则无效果
  setTimeout(() => {
    this.listScroller.scrollEdge(Edge.End);
  }, 100)
}

实时语音转文字

  1. 语音转文字需使用麦克风权限,需要在module.json5文件中申明麦克风权限,使用时去请求麦克风权限
typescript 复制代码
// module.json5中申明权限
"requestPermissions": [
  {
    "name": "ohos.permission.MICROPHONE",
    "reason": "$string:microphone_reason",
    "usedScene": {
      "abilities": [
        "EntryAbility"
      ],
      "when": "always"
    },
  }
]

// 页面即将加载时,请求麦克风权限
async aboutToAppear(): Promise<void> {
  this.speechRecognizer.intiEngine();
  await requestPermission(['ohos.permission.MICROPHONE'], getContext() as common.UIAbilityContext);
}

// 调用系统API请求所需权限
export async function requestPermission(permissions: Permissions[], context: common.UIAbilityContext): Promise<boolean> {
  let atManager: abilityAccessCtrl.AtManager = abilityAccessCtrl.createAtManager();
  const result = await atManager.requestPermissionsFromUser(context, permissions);
  return !!result.authResults.length && result.authResults.every(authResults => authResults === 0);
}
  1. 调用系统speechRecognizerAPI进行实时语音识别,具体代码如下:
typescript 复制代码
import { speechRecognizer } from '@kit.CoreSpeechKit';

export class SpeechRecognizer {
  private engineParams: speechRecognizer.CreateEngineParams = {
    language: 'zh-CN',  // 目前系统API只支持设置中文
    online: 1,          // 目前系统API只支持离线模式
    extraParams: { 'locate': 'CN', 'recognizerMode': 'long' }   // 设置recognizerMode为长时模式,设置短时模式时说完一句话会自动结束识别
  };
  private asrEngine?: speechRecognizer.SpeechRecognitionEngine;
  private sessionId: string = 'SpeechRecognizer_' + Date.now();

  public async intiEngine() {
    this.asrEngine = await speechRecognizer.createEngine(this.engineParams);
  }

  // 开始语音识别,并提供回调函数,用于返回结果
  public start(callback: (srr: speechRecognizer.SpeechRecognitionResult) => void = () => {}) {
    this.setListener(callback);
    this.startListening();
  }

  // 停止语音识别
  public stop() {
    this.asrEngine?.finish(this.sessionId);
  }

  public shutdown() {
    this.asrEngine?.shutdown();
  }

  // 启动监听
  private startListening() {
    let recognizerParams: speechRecognizer.StartParams = {
      sessionId: this.sessionId,
      audioInfo: { audioType: 'pcm', sampleRate: 16000, soundChannel: 1, sampleBit: 16 },
      extraParams: { recognitionMode: 0, maxAudioDuration: 60000 }
    }
    this.asrEngine?.startListening(recognizerParams);
  }

  private setListener(callback: (srr: speechRecognizer.SpeechRecognitionResult) => void = () => {}) {
    let listener: speechRecognizer.RecognitionListener = {
      onStart(sessionId: string, eventMessage: string) {
      },
      onEvent(sessionId: string, eventCode: number, eventMessage: string) {
      },
      onResult(sessionId: string, result: speechRecognizer.SpeechRecognitionResult) {
        // 语音识别到结果后,通过回调函数将识别的结果返回
        callback && callback(result);
      },
      onComplete(sessionId: string, eventMessage: string) {
        // recognizerMode设置为短时模式时,如果仍需继续识别,需要在此处再次调用startListening,启动监听。
      },
      onError(sessionId: string, errorCode: number, errorMessage: string) {
      },
    }
    this.asrEngine?.setListener(listener);
  }
}

根据光标位置插入语音识别的文字

通过getCaretOffset获取到光标所在位置。在插入文字时设置对应的偏移量来达成目的,RichEditor输入框无内容时增加正在识别...文字提示,有内容时增加...内容提示

typescript 复制代码
startSpeechRecognizer() {
  // 输入框无内容时,直接使用提示文本
  this.placeHolder = '正在识别...';
  this.fillPlaceHolder();   // 填充提示文本
  this.speechRecognizer.start((result) => {
    this.insertSpan(result.result);     // 处理语音识别到的结果
    if (result.isFinal) {   // 语音识别完,将caretOffset,speechTextLength重置
      this.caretOffset = richController.getCaretOffset();
      this.speechTextLength = 0;
    }
  })
}

stopSpeechRecognizer() {
  this.placeHolder = '';
  this.speechRecognizer.stop();
  // 结束识别,需将插入的...提示删除
  richController.deleteSpans({ start: this.caretOffset, end: this.caretOffset + 3 });
}

private insertSpan(text: string) {
    if (text.length <= 0) {     // 未识别到内容时,直接return
      return;
    }

    if (this.speechTextLength === 0) {  // 首次识别,直接根据光标位置将识别到的文字插入
      richController.addTextSpan(text, { offset: this.caretOffset });
    } else {
      // 非首次识别,需先将上次识别的文字删除,再填充新识别的文字
      richController.deleteSpans({ start: this.caretOffset, end: this.caretOffset + this.speechTextLength });
      richController.addTextSpan(text, { offset: this.caretOffset });
    }
    this.speechTextLength = text.length;    // 每次记录识别文字的长度
  }

private fillPlaceHolder() {
  this.caretOffset = richController.getCaretOffset();
  if (richController.getSpans().length > 0) {
    // 输入框有内容时,插入...提示,并更新caretOffset位置
    richController.addTextSpan('...', {
    offset: this.caretOffset,
    style: {
        fontColor: '#4D242E3E',
        fontSize: 13,
      }
    })
    richController.setCaretOffset(this.caretOffset);
  }
}
相关推荐
2分钟速写快排18 分钟前
什么是 RAG?如何用 RAG 实现一个用户记忆?
前端·后端·ai编程
passerby60611 小时前
如何自己造一个时间处理库
前端·javascript·github
走到天涯海角2 小时前
react里面的长列表渲染优化
前端·react.js·前端框架
小羊没烦恼!2 小时前
Hello Web API系列教程——Web API与国际化
java·服务器·前端·javascript·php
北岛贰2 小时前
迷茫焦虑期,我做了一个带支付带官网的 AI 聊天虚拟恋人 App
前端·人工智能·后端
mayaairi4 小时前
Vue2 组件通讯(三):全局事件总线、PubSub、插槽与组件实例属性
前端·javascript·vue.js
kyriewen4 小时前
面试官问我:AI 都能写代码了,前端凭什么还值 25K
前端·javascript·人工智能
风骏时光牛马5 小时前
AI源码分析:拆解模型底层实现逻辑
前端
IT_陈寒5 小时前
React子组件莫名其妙重渲染?你可能漏了这个Hook
前端·人工智能·后端
乘风gg6 小时前
DeepSeek V4.1 Flash 来了,明天中午 Flash 降价 60%
前端·ai编程·claude