1. 获取权限
1.1 封装了获取权限的工具类
import { abilityAccessCtrl, bundleManager, Permissions } from '@kit.AbilityKit'
class PermissionPlugin {
// 检查单个权限
checkAccessToken(permission: Permissions) {
try {
const atManager = abilityAccessCtrl.createAtManager();
const bundleInfo =
bundleManager.getBundleInfoForSelfSync(bundleManager.BundleFlag.GET_BUNDLE_INFO_WITH_APPLICATION)
const accessTokenId = bundleInfo.appInfo.accessTokenId
const grantStatus = atManager.checkAccessTokenSync(accessTokenId, permission);
return grantStatus;
} catch (e) {
return abilityAccessCtrl.GrantStatus.PERMISSION_DENIED
}
}
// 检查多个权限
async checkPermissions(permissions: Permissions[]) {
for (let permission of permissions) {
const grantStatus = this.checkAccessToken(permission)
if (grantStatus === abilityAccessCtrl.GrantStatus.PERMISSION_DENIED) {
return false
}
}
return true
}
// 首次向用户申请授权
async requestPermissions(permissions: Permissions[]) {
const atManager = abilityAccessCtrl.createAtManager();
const res = await atManager.requestPermissionsFromUser(getContext(), permissions) // 首次
const isAuth = res.authResults.every(item => item === abilityAccessCtrl.GrantStatus.PERMISSION_GRANTED)
return isAuth ? Promise.resolve() : Promise.reject()
}
// 二次向用户申请授权
async requestPermissionOnSetting(permissions: Permissions[]) {
const atManager = abilityAccessCtrl.createAtManager();
const grantStatus = await atManager.requestPermissionOnSetting(getContext(), permissions) // 二次
const isAuth = grantStatus.every(item => item === abilityAccessCtrl.GrantStatus.PERMISSION_GRANTED)
return isAuth ? Promise.resolve() : Promise.reject()
}
}
// 暴露实例对象
export const permissionPlugin = new PermissionPlugin()
1.2 申请录音权限
src/main/module.json5
"requestPermissions": [
{
"name": "ohos.permission.MICROPHONE",
"reason": "$string:app_name",
"usedScene": {"abilities": ["EntryAbility"]}
}
],
1.3 调用方法获取权限
aboutToAppear() {
// 首次获取权限
permissionPlugin.requestPermissions(["ohos.permission.MICROPHONE"])
.catch(() => {
// 用户拒绝 去设置页继续申请
permissionPlugin.requestPermissionOnSetting(['ohos.permission.MICROPHONE'])
})
}
运行效果
2. 实现录音计时功能
2.1 定义播放状态和时间戳
@State recordIng: boolean = false // 是否正在播放
@State timeStamp: number = 0 // 录音时间戳
2.2 检查权限
const grantStatus = permissionPlugin.checkAccessToken("ohos.permission.MICROPHONE")
2.3 开始录音并计时
aboutToAppear(): void {
const grantStatus = permissionPlugin.checkAccessToken("ohos.permission.MICROPHONE")
if (grantStatus === abilityAccessCtrl.GrantStatus.PERMISSION_DENIED) {
// 申请权限
permissionPlugin.requestPermissionOnSetting(["ohos.permission.MICROPHONE"])
} else {
// 开始录音
this.beginRecord()
}
}
beginRecord() {
this.recordIng = true
setInterval(() => {
this.timeStamp += 10
}, 10)
}
getDisplayTime 函数用于将时间戳转换为显示时间格式(分钟:秒)。
~~(this.timeStamp / 60 / 1000):计算分钟数,并向下取整。
(this.timeStamp / 1000 % 60).toFixed(2):计算秒数,并保留两位小数。
padStart(2, "0") 和 padStart(5, "0"):确保分钟和秒数分别至少有两位和五位数字,不足部分用0补齐。
getDisplayTime() {
return `${(~~(this.timeStamp / 60 / 1000)).toString().padStart(2, "0")}: ${(this.timeStamp / 1000 % 60).toFixed(2)
.padStart(5, "0")}`
}
运行结果
3. 封装音频录制管理类
1.定义类 AudioRecordManager
定义了一个名为 AudioRecordManager 的类,用于管理音频录制的整个过程。类中包含一个可选属性 capturer,表示音频捕获器的实例。
2.创建音频捕获器实例
定义了一个异步方法 createCapturer,用于创建 AudioCapturer 实例。这个方法的主要步骤如下:
1.初始化音频流的信息,包括采样率、通道、采样格式和编码格式。
2.初始化音频捕获器的信息,包括音频来源和捕获器标志。
3.使用上述信息创建 AudioCapturer 实例,并将其赋值给 capturer 属性。
3.开始录音
定义了一个异步方法 start,用于开始录音。这个方法的主要步骤如下:
1.检查 capturer 是否已创建,如果没有则调用 createCapturer 方法创建。
2.监听 readData 事件,当有录音数据时,调用传入的回调函数处理数据。
3.调用 capturer 的 start 方法开始录音。
4.结束录音
定义了一个异步方法 stop,用于结束录音。这个方法的主要步骤如下:
1.检查 capturer 是否存在,如果存在则调用其 stop 方法停止录音。
2.释放 capturer 的资源,并将 capturer 属性设为 undefined,以释放内存。
5.创建单例
创建了一个 AudioRecordManager 类的单例 audioRecordManager,以便在整个应用中使用。
import { audio } from '@kit.AudioKit';
/**
* 音频录制管理器,负责管理音频的录制过程
*/
export class AudioRecordManager {
// 录音引擎的单例
capturer?: audio.AudioCapturer
/**
* 创建AudioCapturer实例
* 这个方法是异步的,因为它需要初始化音频流和捕获器设置,并创建AudioCapturer实例
*/
async createCapturer() {
// 音频流的信息,包括采样率、通道、采样格式和编码格式
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 audioCapturerInfo: audio.AudioCapturerInfo = {
// 音频来源,这里设置为麦克风
source: audio.SourceType.SOURCE_TYPE_MIC,
// 捕获器标志,这里设置为0
capturerFlags: 0
};
// 音频捕获器的选项,包含音频流和捕获器信息
let audioCapturerOptions: audio.AudioCapturerOptions = {
// 音频流信息
streamInfo: audioStreamInfo,
// 捕获器信息
capturerInfo: audioCapturerInfo
};
// 创建AudioCapturer实例对象
this.capturer = await audio.createAudioCapturer(audioCapturerOptions);
}
/**
* 开始录音
* @param callBack 当录音数据可用时调用的回调函数,它接收一个ArrayBuffer作为参数
* 这个方法是异步的,如果capturer尚未创建,需要先创建capturer
*/
async start(callBack: (bf: ArrayBuffer) => void) {
// 如果capturer不存在,则创建一个新的capturer实例
if (!this.capturer) {
await this.createCapturer()
}
// 监听"readData"事件,当有录音数据时,调用回调函数处理数据
this.capturer?.on("readData", (buffer) => {
callBack(buffer)
})
// 开始录音
this.capturer?.start()
}
/**
* 结束录音
* 这个方法是异步的,负责停止录音并释放资源
*/
async stop() {
// 如果capturer存在,则停止录音并释放资源
if (this.capturer) {
await this.capturer.stop()
this.capturer.release() // 释放资源
this.capturer = undefined // 这行代码值五十块钱 释放内存 因为已经结束任务
}
}
}
// 音频录制管理器的单例
export const audioRecordManager = new AudioRecordManager()
改造
1.创建沙箱文件
getContext().filesDir:获取应用的私有文件目录路径,这个目录通常位于应用的沙箱环境中。
newFileName:包含 .mp3 扩展名的文件名。
fileIo.openSync:同步打开或创建文件。
fileIo.OpenMode.CREATE | fileIo.OpenMode.READ_WRITE:指定文件打开模式,
CREATE 表示如果文件不存在则创建, READ_WRITE 表示以读写模式打开文件。
// 为文件名添加.mp3扩展名
const newFileName = fileName + ".mp3"
// 打开或创建文件,准备写入录音数据
const file =
fileIo.openSync(getContext().filesDir + '/' + newFileName, fileIo.OpenMode.CREATE | fileIo.OpenMode.READ_WRITE)
2.根据偏移量写入buffer
file.fd:文件描述符,表示已打开的文件。
buffer:要写入的录音数据,类型为 ArrayBuffer。
offset:文件中的当前偏移量,表示从文件的哪个位置开始写入数据。
length:要写入的数据长度,即 buffer 的字节长度。
// 监听录音数据事件,当有数据时调用回调函数并写入文件
this.capturer?.on("readData", (buffer) => {
if (buffer) {
callBack(buffer)
// 写入文件
fileIo.writeSync(file.fd, buffer, {
offset: offset,
length: buffer.byteLength
})
offset += buffer.byteLength
}
})
3.如果文件存在,则在原文件的基础上继续写入
// 获取文件初始偏移量,用于后续写入数据
let offset = fileIo.statSync(file.fd).size
完整代码
/**
* 异步启动录音功能,并将录音数据回调和保存到文件中
*
* @param callBack 回调函数,当有录音数据可用时调用,接收一个ArrayBuffer类型的录音数据
* @param fileName 要保存的文件名,不包含扩展名
*/
async start(callBack: (bf: ArrayBuffer) => void, fileName: string) {
// 如果capturer尚未创建,则进行创建
if (!this.capturer) {
await this.createCapturer()
}
// 为文件名添加.mp3扩展名
const newFileName = fileName + ".mp3"
// 打开或创建文件,准备写入录音数据
const file =
fileIo.openSync(getContext().filesDir + '/' + newFileName, fileIo.OpenMode.CREATE | fileIo.OpenMode.READ_WRITE)
// 获取文件初始偏移量,用于后续写入数据
let offset = fileIo.statSync(file.fd).size
// 监听录音数据事件,当有数据时调用回调函数并写入文件
this.capturer?.on("readData", (buffer) => {
if (buffer) {
callBack(buffer)
// 写入文件
fileIo.writeSync(file.fd, buffer, {
offset: offset,
length: buffer.byteLength
})
offset += buffer.byteLength
}
})
// 开始录音
this.capturer?.start()
}
4.实现录音功能
4.1 定义文件名称和定时器id
fileName: string = "" // 文件名称
timer: number = -1 // 定时器id
4.2 改造开始录音方法
/**
* 开始录音记录
* 此函数初始化录音过程,包括启动定时器记录时间戳和开始录音文件的录制
*/
beginRecord() {
this.recordIng = true // 标记录音状态为正在录音
this.timer = setInterval(() => {
this.timeStamp += 10 // 每10毫秒增加一次时间戳
}, 10)
if (!this.fileName) {
this.fileName = Date.now().toString() // 如果未指定文件名,则使用当前时间戳作为文件名
}
audioRecordManager.start((bf) => {
// 开始录音的回调函数,此处未执行任何操作
}, this.fileName) // 开始录音
}
4.3 暂停录音逻辑实现
/**
* 暂停录音记录
* 此函数停止录音过程,包括停止录音输入和清除计时器
*/
async pauseRecord() {
audioRecordManager.stop() // 停止录音输入
this.recordIng = false // 标记录音状态为未在录音
clearInterval(this.timer) // 清除定时器
}
4.4 注册点击事件
Row() {
Image(this.recordIng ? $r("sys.media.ohos_ic_public_pause") : $r("sys.media.ohos_ic_public_play"))
.width(24)
.aspectRatio(1)
}
.onClick(() => {
if (this.recordIng) {
this.pauseRecord()
} else {
this.beginRecord()
}
})
4.5 运行结果展示(到这里已经实现了录音功能)
应用沙箱中生成一个MP3文件
5.渲染列表
获取数据
// 保存录音数据
async saveRecord() {
let file = fileIo.statSync(this.filePath)
let data: InterviewAudioItem = {
id: this.startTime,
name: this.filePath.split('/').pop()!,
path: this.filePath,
duration: this.endTime - this.startTime,
size: file.size
}
const pre = preferences.getPreferencesSync(getContext(), { name: 'recordAudioList' })
pre.putSync(data.id.toString(), JSON.stringify(data))
await pre.flush()
}
// 获取录音列表
async getRecordAudioList() {
const pre = preferences.getPreferencesSync(getContext(), { name: 'recordAudioList' })
let objlist = pre.getAllSync()
let list = Object.values(objlist) as string[]
let recordAudioList = list.map(value => {
return JSON.parse(value) as InterviewAudioItem
})
return recordAudioList
}
// 获取沙箱中的文件
onPageShow(): void {
this.getPlayList()
}
/**
* 获取播放列表
* 该方法用于扫描应用文件目录下的所有.mp3文件,并将它们的信息加载到列表中
* 这有助于创建一个包含所有可用音频文件的播放列表
*/
async getPlayList() {
this.list = await audioRecordManager.getRecordAudioList()
}
引入dayjs
ohpm install dayjs
渲染列表
List({ space: 20 }) {
ForEach(this.list, (item: InterviewAudioItem) => {
ListItem() {
Column({ space: 10 }) {
Row({ space: 10 }) {
Column({ space: 10 }) {
Text(item.name).fontSize(16).fontWeight(600)
Text(dayjs(item.id).format("YYYY-MM-DD HH:mm:ss")).fontSize(14).fontColor('#999999')
}.alignItems(HorizontalAlign.Start)
Image(this.playName === item.name && this.playIng ?
$r("sys.media.ohos_ic_public_pause")
: $r("sys.media.ohos_ic_public_play"))
.width(24)
.aspectRatio(1)
}
.width('100%')
.justifyContent(FlexAlign.SpaceBetween)
if (this.isShow && item.name === this.playName) {
Column() {
Slider({
min: 0,
max: this.max,
value: this.value
})
.onChange((value) => {
this.value = value
})
Row() {
Text('0')
.fontSize(14)
.fontColor('#999999')
Text(dayjs(this.max).format("HH:mm:ss"))
.fontSize(14)
.fontColor('#999999')
}
.padding(10)
.width('100%')
.justifyContent(FlexAlign.SpaceBetween)
}
}
}
.padding(10)
.backgroundColor(Color.White)
.borderRadius(15)
}
})
}
.margin({ top: 50 })
.padding({ left: 20, right: 20, top: 20 })
6.实现音频播放
6.1 音频渲染管理器类
import { audio } from '@kit.AudioKit';
import { fileIo } from '@kit.CoreFileKit';
/**
* 音频渲染管理器类,负责管理音频的播放流程
*/
export class AudioRenderManager {
// 音频渲染器实例,初始为null
render: audio.AudioRenderer | null = null
/**
* 初始化音频渲染器
*/
async init() {
// 音频流信息配置
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_VOICE_COMMUNICATION,
rendererFlags: 0
};
// 音频渲染器选项配置
let audioRendererOptions: audio.AudioRendererOptions = {
streamInfo: audioStreamInfo,
rendererInfo: audioRendererInfo
};
// 创建音频渲染器
this.render = await audio.createAudioRenderer(audioRendererOptions)
}
/**
* 开始播放音频
* @param fileName 音频文件名
* @param callback 播放完成后的回调函数
*/
async start(fileName: string, callback: () => void) {
// 如果渲染器未初始化,则进行初始化
if (!this.render) {
await this.init()
}
let offset = 0
// 同步打开音频文件
const file = fileIo.openSync(getContext().filesDir + "/" + fileName)
const statFile = fileIo.statSync(file.fd)
// 监听渲染器的writeData事件,以读取和播放音频数据
this.render?.on("writeData", (bf: ArrayBuffer) => {
fileIo.readSync(file.fd, bf, {
offset: offset,
length: bf.byteLength
})
offset += bf.byteLength
// 如果达到文件末尾,则调用回调函数并停止播放
if (offset >= statFile.size) {
callback()
this.stop()
}
})
// 开始播放音频
this.render?.start()
}
/**
* 暂停音频播放
*/
async pause() {
if (this.render) {
await this.render.pause()
}
}
/**
* 停止音频播放,并释放资源
*/
async stop() {
if (this.render) {
await this.render.stop()
await this.render.release()
this.render = null
}
}
}
// 音频渲染管理器的单例实例
export const audioRenderManager = new AudioRenderManager()
6.2 设置点击事件
Image(this.playName === item.name && this.playIng ?
$r("sys.media.ohos_ic_public_pause")
: $r("sys.media.ohos_ic_public_play"))
.width(24)
.aspectRatio(1)
.onClick(async () => {
// 判断当前是否正在播放
if (this.playName === item.name) {
// 一个是 暂停 一个是播放
if (this.playIng) {
await audioRenderManager.stop()
// 这里不能有后续逻辑了!!!
} else {
audioRenderManager.start(item.path.split('/').pop()!, () => {
// 结束时触发
this.playIng = false
})
}
this.playIng = !this.playIng
} else {
// 第一次点击任何一个播放
this.isShow = true
this.max = item.duration
this.playName = item.name // 记录当前播放的名称
// 如果正在播放 一定是其他的在播放
await audioRenderManager.stop()
this.playIng = true // 这里无论如何得设置一下 万一别人没播呢?
audioRenderManager.start(item.path.split('/').pop()!, () => {
// 结束时触发
this.playIng = false
})
}
})
7 实现删除功能
// 删除录音
async delRecordAudioList(id: number) {
const pre = preferences.getPreferencesSync(getContext(), { name: 'recordAudioList' })
let str = pre.getSync(id.toString(), '') as string
let obj = JSON.parse(str) as InterviewAudioItem
fileIo.unlinkSync(obj.path) // 删除文件
pre.deleteSync(id.toString())
await pre.flush()
}
.swipeAction({
end: this.getListItemEnd(item.id)
})
@Builder
getListItemEnd(id: number) {
Column() {
Text("删除")
.fontSize(12)
.fontColor(Color.White)
.fontWeight(FontWeight.Bold)
}
.margin(10)
.justifyContent(FlexAlign.Center)
.height(40)
.width(40)
.borderRadius(20)
.backgroundColor(Color.Red)
.onClick(async () => {
// 弹窗确认
promptAction.showDialog({
title: '提示',
message: '确定删除吗?',
buttons: [
{
text: '确定',
color: '#f40',
},
{
text: '取消',
color: '#999999',
}
],
}).then(async (value) => {
if (value.index === 0) {
audioRecordManager.delRecordAudioList(id)
this.list = await audioRecordManager.getRecordAudioList()
}
})
})
}
8 保存录音
8.1封装一个弹窗实现返回时确定文件名称
import { promptAction } from '@kit.ArkUI'
@CustomDialog
@Component
export struct FileNameDialog {
controller: CustomDialogController
@State
fileName: string = ""
onConfirm: (name: string) => void = () => {
}
onCancel: () => void = () => {
}
build() {
Column({ space: 20 }) {
Column({ space: 20 }) {
Text("保存当前音频文件为手机音频?")
TextInput({
text: $$this.fileName,
placeholder: '请输入音频名称'
})
.height(36)
}
.padding({
top: 20
})
.layoutWeight(1)
Row() {
Text("删除")
.fontColor(Color.White)
.layoutWeight(1)
.textAlign(TextAlign.Center)
.backgroundColor(Color.Red)
.height("100%")
.borderRadius(10)
.onClick(() => {
this.onCancel() // 李姐
})
Text("确定")
.fontColor(Color.White)
.layoutWeight(1)
.textAlign(TextAlign.Center)
.backgroundColor(Color.Blue)
.height("100%")
.borderRadius(10)
.onClick(() => {
if (this.fileName) {
this.onConfirm(this.fileName)
} else {
promptAction.showToast({ message: '录音名称不能为空' })
}
})
}
.height(40)
}
.width(280)
.height(200)
.backgroundColor(Color.White)
.borderRadius(10)
.padding(10)
}
}
8.2 修改录音名字
// 修改录音名字
async rename(id: number, name: string) {
const pre = preferences.getPreferencesSync(getContext(), { name: 'recordAudioList' })
let str = pre.getSync(id.toString(), '') as string
let obj = JSON.parse(str) as InterviewAudioItem
let newPath = obj.path.replace(obj.name, name)
fileIo.renameSync(obj.path, newPath)
obj.name = name
obj.path = newPath
pre.putSync(id.toString(), JSON.stringify(obj))
}
8.3 弹窗
dialog: CustomDialogController = new CustomDialogController({
builder: FileNameDialog({
fileName: this.fileName,
onConfirm: async (name: string) => {
if ((await audioRecordManager.getRecordAudioList()).some(item => item.name === name)) {
// 如果存在同名文件,则提示并返回
promptAction.showToast({
message: "文件名已存在",
duration: 2000
})
return
}
audioRecordManager.rename(audioRecordManager.startTime, name)
this.dialog.close()
router.back()
},
onCancel: () => {
audioRecordManager.delRecordAudioList(audioRecordManager.startTime)
this.dialog.close()
router.back()
}
}),
alignment: DialogAlignment.Center,
customStyle: true
})
Row() {
Row() {
Row()
.width(18)
.aspectRatio(1)
.borderRadius(4)
.backgroundColor(Color.White)
}
.justifyContent(FlexAlign.Center)
.width(50)
.aspectRatio(1)
.backgroundColor(Color.Red)
.borderRadius(25)
.onClick(() => {
// 停止录音
this.pauseRecord()
this.dialog.open()
})
9 实现波形计算
9.1封装波形音频组件
@Component
export struct AudioWave {
controller: WaveController = new WaveController()
@State
list: number[] = []
aboutToAppear(): void {
this.initController()
}
initController() {
this.controller.transBuffer = (bf: ArrayBuffer) => {
this.calculateAmplitudes(bf)
}
}
calculateAmplitudes(buffer: ArrayBuffer) {
try {
const sampleSize = Math.floor(buffer.byteLength / 80); // 计算每个均分区间的大小
const view = new DataView(buffer);
const amplitudes: number[] = [];
// 遍历缓冲区,计算每个样本的振幅并转换为高度
// 遍历原始缓冲区,提取每个均分区间的振幅
for (let i = 0; i < buffer.byteLength; i += sampleSize) {
let sum = 0;
// 计算当前均分区间内所有振幅的平均值
for (let j = i; j < i + sampleSize && j < buffer.byteLength; j += 2) { // 假设每个样本占据 2 字节
const sample = view.getInt16(j, true);
sum += Math.abs(sample);
}
const averageAmplitude = 500 * sum / (sampleSize / 2) / 32767; // 假设每个样本占据 2 字节
amplitudes.push(averageAmplitude < 10 ? 10 : averageAmplitude);
}
animateTo({ duration: 100 }, () => {
this.list = amplitudes
})
} catch (error) {
AlertDialog.show({ message: error.message })
throw new Error(error.message)
}
}
build() {
Row({ space: 2 }) {
ForEach(this.list, (val: number) => {
Row()
.width(2)
.height(val)
.backgroundColor("#bababa")
})
}
.width("100%")
.height(240)
.backgroundColor("#f6f6f6")
.justifyContent(FlexAlign.Center)
}
}
export class WaveController {
transBuffer: (bf: ArrayBuffer) => void = () => {
}
}
controller: WaveController = new WaveController()
// 底部音轨区
AudioWave({
controller: this.controller
})
}
.width("100%")
.height(400)
.backgroundColor(Color.White)
.borderRadius(10)