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

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

介绍

本示例介绍如何使用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);
  }
}
相关推荐
Python私教27 分钟前
使用FastAPI和React以及MongoDB构建全栈Web应用05 FastAPI快速入门
前端·react.js·fastapi
浪裡遊30 分钟前
Typescript中的对象类型
开发语言·前端·javascript·vue.js·typescript·ecmascript
杨-羊羊羊37 分钟前
什么是深拷贝什么是浅拷贝,两者区别
开发语言·前端·javascript
发呆的薇薇°42 分钟前
在vue里,使用dayjs格式化时间并实现日期时间的实时更新
前端·javascript·vue.js
七冬与小糖1 小时前
【本地搭建npm私服】使用Verdaccio
前端·npm·node.js
lally.1 小时前
2025御网杯wp(web,misc,crypto)
前端·ctf
海绵不是宝宝8171 小时前
React+Springboot项目部署ESC服务器
前端·react.js·前端框架
前端小崔1 小时前
从零开始学习three.js(15):一文详解three.js中的纹理映射UV
前端·javascript·学习·3d·webgl·数据可视化·uv
ZHOU_WUYI2 小时前
React 实现 JWT 登录验证的最小可运行示例
前端·react.js·前端框架
一只程序熊2 小时前
【uniapp】errMsg: “navigateTo:fail timeout“
服务器·前端·uni-app