我写个HarmonyOS Next版本的微信聊天02-完结篇
接上一篇
前言
代码会统一放在码云上
案例目标
这个是安卓手机上的真正的微信聊天界面功能效果
实际效果
案例功能
上一篇,已经实现了以下功能
- 页面沉浸式
- 聊天内容滚动
- 输入框状态切换
- 聊天信息框宽度自适应
- 输入法避让
- canvas声纹 按住说话
- 发送文字
- 录音-发送语音
- 语音消息根据时长自动宽度
- 手势坐标检测取消发送-语音转文字
- 声音播放-语音消息
- AI 语音转文字
发送声音-功能演示
发送声音主要流程
发送声音结合UI交互-主要流程
声明麦克风权限
应用需要在module.json5配置文件的requestPermissions标签中声明权限。
属性名称 | 含义 | 是否可缺省 |
name | 标识需要使用的权限名称。取值范围请参考应用权限列表。 | 该标签不可缺省,且必须为系统定义权限或definePermissions中定义的权限。 |
reason | 标识申请权限的原因,取值需要采用资源引用格式,以适配多语种。 | **说明:**当申请的权限为user_grant权限时,该字段必填,否则不允许在应用市场上架。 |
usedScene | 标识权限使用的场景,包含abilities和when两个子标签。- - when:表示调用时机,支持的取值包括inuse(使用时)和always(始终)。 | 当申请的权限为user_grant权限时,abilities标签在hap中必填,when标签可选。 |
\entry\src\main\module.json5
requestPermissions
{ "module": { ... "requestPermissions": [ { "name": "ohos.permission.MICROPHONE", "reason": "$string:voice_reason", "usedScene": { "abilities": [ "FormAbility" ], "when": "always" } } ], } }
\entry\src\main\resources\base\element\string.json
$string:voice_reason"
{ "string": [ { "name": "module_desc", "value": "module description" }, { "name": "EntryAbility_desc", "value": "description" }, { "name": "EntryAbility_label", "value": "label" }, { "name": "voice_reason", "value": "用于获取用户的录音" } ] }
封装申请权限的工具类
权限工具类的主要功能为:
- 检测是否已经申请相关权限
- 申请相关权限
步骤:
- 新建文件
\entry\src\main\ets\utils\permissionMananger.ets
- 实现以下代码
// 导入必要的模块,包括权限管理相关的功能 import { abilityAccessCtrl, bundleManager, common, Permissions } from '@kit.AbilityKit'; export class PermissionManager { // 静态方法用于检查给定的权限是否已经被授予 static checkPermission(permissions: Permissions[]): boolean { // 创建一个访问令牌管理器实例 let atManager: abilityAccessCtrl.AtManager = abilityAccessCtrl.createAtManager(); // 初始化tokenID为0,稍后将获取真实的tokenID let tokenID: number = 0; // 获取本应用的包信息 const bundleInfo = bundleManager.getBundleInfoForSelfSync(bundleManager.BundleFlag.GET_BUNDLE_INFO_WITH_APPLICATION); // 设置tokenID为应用的访问令牌ID tokenID = bundleInfo.appInfo.accessTokenId; // 如果没有传入任何权限,则返回false表示没有权限 if (permissions.length === 0) { return false; } else { // 检查所有请求的权限是否都被授予 return permissions.every(permission => abilityAccessCtrl.GrantStatus.PERMISSION_GRANTED === atManager.checkAccessTokenSync(tokenID, permission) ); } } // 异步静态方法用于请求用户授权指定的权限 static async requestPermission(permissions: Permissions[]): Promise<boolean> { // 创建一个访问令牌管理器实例 let atManager: abilityAccessCtrl.AtManager = abilityAccessCtrl.createAtManager(); // 获取上下文(这里假设getContext是一个可以获取到UI能力上下文的方法) let context: Context = getContext() as common.UIAbilityContext; // 请求用户授权指定的权限 const result = await atManager.requestPermissionsFromUser(context, permissions); // 检查请求结果是否成功(authResults数组中每个元素都应该是0,表示成功) return !!result.authResults.length && result.authResults.every(authResults => authResults === 0); } }
- 首页中 aboutToAppear 中调用
// 页面开始显示触发 async aboutToAppear() { // 1 检查是否有权限 // 2 如果没有权限 再去申请权限 const permissionList: Permissions[] = ["ohos.permission.MICROPHONE"] const result = await PermissionManager.checkPermission(permissionList) if (!result) { PermissionManager.requestPermission(permissionList) } }
- 最后效果
声音录制 AudioCapturer
AudioCapturer是音频采集器,用于录制PCM(Pulse Code Modulation)音频数据
封装录制声音类
根据上图的AudioCapturer使用流程,我们将封装 AudioCapturer录音类,主要有三个核心方法:
- 创建 AudioCapturer实例
- 开始录音
- 停止录音
\entry\src\main\ets\utils\AudioCapturerManager.ets
// 导入音频处理模块 import { audio } from '@kit.AudioKit'; // 导入文件系统模块 import fs from '@ohos.file.fs'; // 定义一个接口来描述录音文件的信息 export interface RecordFile { recordFilePath: string, // 录音文件的路径 startRecordTime: number, // 开始录音的时间戳 endRecordTime: number // 结束录音的时间戳 } // 定义一个管理音频录制的类 export class AudioCapturerManager { // 静态属性,用于存储当前的音频捕获器实例 static audioCapturer: audio.AudioCapturer | null = null; // 静态私有属性,用于存储录音文件的路径 private static recordFilePath: string = ""; // 静态私有属性,用于存储开始录音的时间戳 private static startRecordTime: number = 0; // 静态私有属性,用于存储结束录音的时间戳 private static endRecordTime: number = 0; // 静态异步方法,用于创建音频捕获器实例 static async createAudioCapturer() { if (AudioCapturerManager.audioCapturer) { return AudioCapturerManager.audioCapturer } // 设置音频流信息配置 let audioStreamInfo: audio.AudioStreamInfo = { samplingRate: audio.AudioSamplingRate.SAMPLE_RATE_16000, // 设置采样率为16kHz channels: audio.AudioChannel.CHANNEL_1, // 设置单声道 sampleFormat: audio.AudioSampleFormat.SAMPLE_FORMAT_S16LE, // 设置样本格式为16位小端 encodingType: audio.AudioEncodingType.ENCODING_TYPE_RAW // 设置编码类型为原始数据 }; // 设置音频捕获信息配置 let audioCapturerInfo: audio.AudioCapturerInfo = { source: audio.SourceType.SOURCE_TYPE_MIC, // 设置麦克风为音频来源 capturerFlags: 0 // 捕获器标志,此处为默认值 }; // 创建音频捕获选项对象 let audioCapturerOptions: audio.AudioCapturerOptions = { streamInfo: audioStreamInfo, // 使用上面定义的音频流信息 capturerInfo: audioCapturerInfo // 使用上面定义的音频捕获信息 }; // 创建音频捕获器实例 AudioCapturerManager.audioCapturer = await audio.createAudioCapturer(audioCapturerOptions); // 返回创建的音频捕获器实例 return AudioCapturerManager.audioCapturer; } // 静态异步方法,用于启动录音过程 static async startRecord(fileName: string) { await AudioCapturerManager.createAudioCapturer() // 记录开始录音的时间戳 AudioCapturerManager.startRecordTime = Date.now(); try { // 初始化缓冲区大小 let bufferSize: number = 0; // 定义一个内部类来设置写入文件时的选项 class Options { offset?: number; // 文件写入位置偏移量 length?: number; // 写入数据的长度 } // 获取应用的文件目录路径 let path = getContext().filesDir; // 设置录音文件的完整路径 let filePath = `${path}/${fileName}.wav`; AudioCapturerManager.recordFilePath = filePath; // 打开或创建录音文件 let file = fs.openSync(filePath, fs.OpenMode.READ_WRITE | fs.OpenMode.CREATE); // 定义一个读取数据的回调函数 let readDataCallback = (buffer: ArrayBuffer) => { // 创建一个写入文件的选项对象 let options: Options = { offset: bufferSize, // 文件当前位置偏移量 length: buffer.byteLength // 数据长度 }; // 将数据写入文件 fs.writeSync(file.fd, buffer, options); // 更新缓冲区大小 bufferSize += buffer.byteLength; }; // 给音频捕获器实例注册读取数据的事件监听器 AudioCapturerManager.audioCapturer?.on('readData', readDataCallback); // 开始录音 AudioCapturerManager.audioCapturer?.start(); // 返回录音文件的路径 return filePath; } catch (e) { // 如果出现异常,返回空字符串 return ""; } } // 静态异步方法,用于停止录音过程 static async stopRecord() { // 停止音频捕获器的工作 await AudioCapturerManager.audioCapturer?.stop(); // 释放音频捕获器的资源 await AudioCapturerManager.audioCapturer?.release(); // 清除音频捕获器实例 AudioCapturerManager.audioCapturer = null; // 记录结束录音的时间戳 AudioCapturerManager.endRecordTime = Date.now(); // 创建并返回一个包含录音文件信息的对象 const recordFile: RecordFile = { recordFilePath: AudioCapturerManager.recordFilePath, startRecordTime: AudioCapturerManager.startRecordTime, endRecordTime: AudioCapturerManager.endRecordTime, }; // 返回记录文件信息 return recordFile; } }
“按住说话” 发送录音
这里我们先实现最简单的录音功能,转换文本或者取消发送下一个环节再实现
- 当长按 按住说话时,便开始录音
- 当直接松开手指时,便停止录音
- 同时构造声音消息,显示在聊天面板上
定义全局录音文件名
// 录音文件名称 recordFileName: string = ""
首页中定义开始录音的方法
// 开始录音 onStartRecord = () => { // 文件名 唯一 this.recordFileName = Date.now().toString() AudioCapturerManager.startRecord(this.recordFileName) }
长按 "按住说话",开始录音
声明停止录音方法
// 结束录音 stopRecord = async () => { // res 记录录音文件的路径、时长等信息 这里返回是为了实现 发送录音消息 const res = await AudioCapturerManager.stopRecord() return res }
松开手指停止录音
在 onPressTalk 中的松开手指事件 TouchType.Up中停止录音
声明发送声音消息的方法
// 生成声音消息 postVoice = (res: RecordFile) => { // 录音时长 录音结束时间-开始录音时间 const duration = Math.ceil((res.endRecordTime - res.startRecordTime) / 1000) // 生成消息文件 const voiceChat = new ChatMessage(MessageType.voice, res.recordFilePath, duration) // 插入到消息数组中 this.chatList.push(voiceChat) }
定义渲染声音消息的自定义构建函数
该部分代码 可以根据声音消息的时长,动态设置消息的宽度
实现的思路为:
- 如果 80 + 时长*3 大于屏幕的一半,那么最大就是屏幕的一半
- 否则 宽度就是 80+时长*3
.width( 80 + duration * 3 > px2vp(display.getDefaultDisplaySync().width / 2) ? px2vp(display.getDefaultDisplaySync().width / 2) : 80 + duration * 3 )
/** * 声音消息 结构 * @param fileName 录音的路径-后续做点击播放使用 * @param time 发送消息的时间 如 22:21 * @param duration 消息的时长 如 5s * @param index 该消息在数组中的索引 后续做声音转文本使用 */ @Builder chatVoiceBuilder(fileName: string, time: string, duration: number, index: number) { Column({ space: 5 }) { Text(time) .width("100%") .textAlign(TextAlign.Center) .fontColor("#666") .fontSize(14) Row() { Flex({ justifyContent: FlexAlign.End }) { Column({ space: 10 }) { Row() { // 声音时长 Text(`${duration}"`) .padding(11) Image($r("app.media.voice")) .width(20) .rotate({ angle: 180 }) .margin({ right: 12 }) Text() .width(10) .height(10) .backgroundColor("#93EC6C") .position({ right: 0, top: 15 }) .translate({ x: 5, }) .rotate({ angle: 45 }); } .backgroundColor("#93EC6C") .margin({ right: 15 }) .borderRadius(5) // 根据声音时长,动态计算声音长度 // 如果 80 + 时长*3 大于屏幕的一半,那么最大就是屏幕的一半 // 否则 宽度就是 80+时长*3 .width(80 + duration * 3 > px2vp(display.getDefaultDisplaySync().width / 2) ? px2vp(display.getDefaultDisplaySync().width / 2) : 80 + duration * 3) .justifyContent(FlexAlign.End) } .alignItems(HorizontalAlign.End) Image($r("app.media.avatar")) .width(40) .aspectRatio(1) } .width("100%") .padding({ left: 40 }) } } .width("100%") }
遍历消息数组时,动态渲染文本消息和声音消息
if (item.type === MessageType.text) { this.chatTextBuilder(item.content, item.time) } else if (item.type === MessageType.voice) { this.chatVoiceBuilder(item.content, item.time, item.duration!, index) }
松开手指停止录音 同时发送声音消息
最后效果
录音生成的文件
生成的录音文件都放在这里了 /data/app/el2/100/base/com.example.你项目的包名/haps/entry/files
“按住说话” 取消发送
该功能主要的实现流程是:
当长按 “按住说话” ,并且判断手指是否移动到了 X(这个功能在上一章已经实现了),如果是,则什么都不做即可
播放声音消息 AudioRendererManager
AudioRenderer是音频渲染器,用于播放PCM(Pulse Code Modulation)音频数据,相比AVPlayer而言,可以在输入前添加数据预处
理,更适合有音频开发经验的开发者,以实现更灵活的播放功能。
封装声音播放类
根据上述的AudioRenderer流程图,我们将封装AudioRendererManager声音播放类,实现了核心的五个功能:
- 初始化AudioRenderer实例
- 开始播放声音
当播放完毕时,会自动停止播放和释放资源 - 暂停播放声音
- 停止播放声音
- 释放AudioRenderer相关资源
\entry\src\main\ets\utils\AudioRendererManager.ets
import { audio } from '@kit.AudioKit'; import { fileIo } from '@kit.CoreFileKit'; class Options { offset?: number; length?: number; } class AudioRendererManager { /** * 音频播放实例 */ private static audioRender: audio.AudioRenderer | null = null /** * 初始化 */ static async init(fileName: string) { try { let bufferSize: number = 0; let audioStreamInfo: audio.AudioStreamInfo = { samplingRate: audio.AudioSamplingRate.SAMPLE_RATE_16000, // 采样率 channels: audio.AudioChannel.CHANNEL_1, // 通道 sampleFormat: audio.AudioSampleFormat.SAMPLE_FORMAT_S16LE, // 采样格式 encodingType: audio.AudioEncodingType.ENCODING_TYPE_RAW // 编码格式 } let audioRendererInfo: audio.AudioRendererInfo = { usage: audio.StreamUsage.STREAM_USAGE_MUSIC, // 音频流使用类型 rendererFlags: 0 // 音频渲染器标志 } let audioRendererOptions: audio.AudioRendererOptions = { streamInfo: audioStreamInfo, rendererInfo: audioRendererInfo } let path = getContext().filesDir; // let filePath = `${path}/${fileName}.wav`; let file: fileIo.File = fileIo.openSync(fileName, fileIo.OpenMode.READ_ONLY); const fileSize = fileIo.statSync(file.path).size let writeDataCallback = (buffer: ArrayBuffer) => { let options: Options = { offset: bufferSize, length: buffer.byteLength } fileIo.readSync(file.fd, buffer, options); bufferSize += buffer.byteLength; // 自动停止 if (bufferSize >= fileSize) { AudioRendererManager.stop() .then(() => { AudioRendererManager.release() }) } } AudioRendererManager.audioRender = await audio.createAudioRenderer(audioRendererOptions) AudioRendererManager.audioRender.on("writeData", writeDataCallback) } catch (e) { console.log("e", e.message, e.code) } } /** 播放 */ static async start() { // 当且仅当状态为prepared、paused和stopped之一时才能启动渲染 await AudioRendererManager.audioRender?.start(); } /** 暂停播放*/ static async pause() { await AudioRendererManager.audioRender?.pause() } /** 结束播放 */ static async stop() { await AudioRendererManager.audioRender?.stop() } /** 释放资源 */ static async release() { await AudioRendererManager.audioRender?.release() } }
export default AudioRendererManager
点击声音消息,播放声音
声明播放录音的函数
// 播放聊天记录中的录音 startPlayRecord = async (fileName: string) => { // console.log("fileName",fileName) // 1 播放录音的实例的初始化 await AudioRendererManager.init(fileName) // 2 播放录音 AudioRendererManager.start() }
给声音消息注册点击事件
事件触发了调用播放语音的方式 startPlayRecord
实时语音转文本
Core Speech Kit(基础语音服务)集成了语音类基础AI能力,包括文本转语音(TextToSpeech)及语音识别(SpeechRecognizer)能力,便于用户与设备进行互动,实现将实时输入的语音与文本之间相互转换
实时语音识别将一段音频信息(短语音模式不超过60s,长语音模式不超过8h)转换为文本。
封装语音识别类
根据以上步骤,我们可以将语音识别拆分成核心功能:
- 创建语音识别引擎
createEngine
- 设置监听的回调
setListener
- 开始监听
startListening
- 取消识别
cancel
- 结束识别
finish
- 释放资源
shutDown
其中针对实际业务,利用上述功能额外组合封装了两个方法
- 停止并且释放资源
release
- 一键开启识别
init
\entry\src\main\ets\utils\SpeechRecognizerManager.ets
import { speechRecognizer } from '@kit.CoreSpeechKit'; import { fileIo } from '@kit.CoreFileKit'; class SpeechRecognizerManager { /** * 语种信息 * 语音模式:长 */ private static extraParam: Record<string, Object> = { "locate": "CN", "recognizerMode": "long" }; private static initParamsInfo: speechRecognizer.CreateEngineParams = { /** * 地区信息 * */ language: 'zh-CN', /** * 离线模式:1 */ online: 1, extraParams: this.extraParam }; /** * 引擎 */ private static asrEngine: speechRecognizer.SpeechRecognitionEngine | null = null /** * 录音结果 */ static speechResult: speechRecognizer.SpeechRecognitionResult | null = null /** * 会话ID */ private static sessionId: string = "asr" + Date.now() /** * 创建引擎 */ private static async createEngine() { // 设置创建引擎参数 SpeechRecognizerManager.asrEngine = await speechRecognizer.createEngine(SpeechRecognizerManager.initParamsInfo) } /** * 设置回调 */ private static setListener(callback: (srr: speechRecognizer.SpeechRecognitionResult) => void = () => { }) { // 创建回调对象 let setListener: speechRecognizer.RecognitionListener = { // 开始识别成功回调 onStart(sessionId: string, eventMessage: string) { }, // 事件回调 onEvent(sessionId: string, eventCode: number, eventMessage: string) { }, // 识别结果回调,包括中间结果和最终结果 onResult(sessionId: string, result: speechRecognizer.SpeechRecognitionResult) { SpeechRecognizerManager.speechResult = result callback && callback(result) }, // 识别完成回调 onComplete(sessionId: string, eventMessage: string) { }, // 错误回调,错误码通过本方法返回 // 如:返回错误码1002200006,识别引擎正忙,引擎正在识别中 // 更多错误码请参考错误码参考 onError(sessionId: string, errorCode: number, errorMessage: string) { }, } // 设置回调 SpeechRecognizerManager.asrEngine?.setListener(setListener); } /** * 开始监听 * */ static startListening() { // 设置开始识别的相关参数 let recognizerParams: speechRecognizer.StartParams = { // 会话id sessionId: SpeechRecognizerManager.sessionId, // 音频配置信息。 audioInfo: { // 音频类型。 当前仅支持“pcm” audioType: 'pcm', // 音频的采样率。 当前仅支持16000采样率 sampleRate: 16000, // 音频返回的通道数信息。 当前仅支持通道1。 soundChannel: 1, // 音频返回的采样位数。 当前仅支持16位 sampleBit: 16 }, // 录音识别 extraParams: { // 0 实时录音识别 "recognitionMode": 0, // 最大支持音频时长 maxAudioDuration: 60000 } } // 调用开始识别方法 SpeechRecognizerManager.asrEngine?.startListening(recognizerParams); }; /** * 取消识别 */ static cancel() { SpeechRecognizerManager.asrEngine?.cancel(SpeechRecognizerManager.sessionId) } /** * 结束识别 */ static finish() { SpeechRecognizerManager.asrEngine?.finish(SpeechRecognizerManager.sessionId) } /** * 释放ai语音转文字引擎 */ static shutDown() { SpeechRecognizerManager.asrEngine?.shutdown() } /** * 停止并且释放资源 */ static async release() { SpeechRecognizerManager.cancel() SpeechRecognizerManager.shutDown() } /** * 初始化ai语音转文字引擎 实现一键开启语音识别 */ static async init(callback: (srr: speechRecognizer.SpeechRecognitionResult) => void = () => { }) { await SpeechRecognizerManager.createEngine() SpeechRecognizerManager.setListener(callback) SpeechRecognizerManager.startListening() } }
export default SpeechRecognizerManager
语音识别业务流程
从上可以看到,我们要做的流程是:
- 在开始 按住说话 时,也直接开启实时语音识别
- 当手指移向 文 时,显示实时识别的文字
- 如果这个时候松开手,那么发送的是文字而不是语音
”按住说话“ 语音识别
声明语音识别的文字状态
// 语音识别的文字 @State voiceToText: string = ""
声明语音识别函数
// 开启ai实时转换声音 onStartSpeechRecognize = () => { // 如果你是完整的一句话,我把它拼接到 this.voiceToText 如果不是,实时显示 // 缓存一段句子的变量 let sentence = "" SpeechRecognizerManager.init((res) => { // console.log("res", JSON.stringify(res)) // isFinal 这一句话 你结束了没有 // isLast 这一段语音你结束了没有 // this.voiceToText = res.result if (res.isFinal) { sentence += res.result this.voiceToText = sentence } else { this.voiceToText = sentence + res.result } }) }
设置转换的文字显示在绿色容器内
在talkContainerBuilder内进行修改
按住说话 开始语音识别监听
松开手 结束语音识别
松开手 发送文字消息
本来是发送录音消息的,但是由于用户进行了语音转文字,所以此时直接松开手,便将语音转成的文字发送成文字消息
定义发送文字消息的方法
// 生成文字消息 postText = () => { // 生成消息文件 const TextChat = new ChatMessage(MessageType.text, this.voiceToText) // 插入到消息数组中 this.chatList.push(TextChat) }
松开手 发送文字消息
松开手 取消发送
松开手时,一共有三种状态
- 直接发送语音
- 语音识别,发送文字
- 取消发送
现在来实现最后的 松开手,取消发送功能
总结
至此,该案例的功能已经全部完成
- 页面沉浸式
- 聊天内容滚动
- 输入框状态切换
- 聊天信息框宽度自适应
- 输入法避让
- canvas声纹 按住说话
- 发送文字
- 录音-发送语音
- 语音消息根据时长自动宽度
- 手势坐标检测取消发送-语音转文字
- 声音播放-语音消息
- AI 语音转文字
致自己,夜幕低垂,万籁俱寂,愿这份灵感之光伴随入梦,在未来的日子里,继续照亮探索之旅。感谢陪伴