带你实现 HarmonyOS Next 微信聊天 02
带你实现 HarmonyOS Next 微信聊天 02
接上一篇
前言
代码会统一放在码云上
案例目标
这个是安卓手机上的真正的微信聊天界面功能效果
实际效果
案例功能
上一篇,已经实现了以下功能
发送声音-功能演示
发送声音主要流程
发送声音结合 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);
};
松开手 发送文字消息
松开手 取消发送
松开手时,一共有三种状态
- 直接发送语音
- 语音识别,发送文字
- 取消发送
现在来实现最后的 松开手,取消发送功能
总结
至此,该案例的功能已经全部完成
致自己,夜幕低垂,万籁俱寂,愿这份灵感之光伴随入梦,在未来的日子里,继续照亮探索之旅。感谢陪伴
源码地址
https://gitee.com/ukSir/hmchat2