关键词:audio、音频录制、音频播放、权限申请、文件管理
在app的开发过程中时常会遇见一些需要播放一段音频或进行语音录制的场景,那么本期将介绍如何利用鸿蒙 audio 模块实现音频写入和播放的功能。本次依赖的是 ohos.multimedia.audio 音频管理模块,核心逻辑为利用 AudioCapturer 创建音频采集器收集音频并写入文件至沙箱,利用 AudioRenderer 播放沙箱中写入的音频文件,确定目标那么开始。
本期文章的完整demo代码已经提交至Gitee:https://gitee.com/luvi/sound-recording
1. 添加权限
需要录音,必不可少的是麦克风权限,需要在 module.json5 中添加 ohos.permission.MICROPHONE 权限。
2. 引导用户授权
在第一步添加完麦克风权限后,app开启后并不能直接使用该权限,用户需要手动确认麦克风权限的开启,在用户手动确认后,麦克风权限则开始在当前app生效。
所以,在代码中我们需要进行访问权限控制弹窗的拉起操作,在这里使用 requestPermissionsFromUser 即可。需要注意的是,若用户拒绝权限后,下次需要引导用户前往设置页手动打开该权限,此处就不做过多逻辑处理,默认用户会同意该权限。
// 此处需要导入权限控制模块
import { abilityAccessCtrl, Permissions,PermissionRequestResult } from '@kit.AbilityKit';
let permissionList: Permissions[] = ["ohos.permission.MICROPHONE"]
// 获取访问控制模块对象
let atManager: abilityAccessCtrl.AtManager = abilityAccessCtrl.createAtManager();
let context: Context = getContext(this) as common.UIAbilityContext;
atManager.requestPermissionsFromUser(context, permissionList, (err: BusinessError, data: PermissionRequestResult) => {
if (err) {
console.error(`luvi > requestPermissionsFromUser fail, err->${JSON.stringify(err)}`);
} else {
// 权限获取成功
console.info('luvi > data:' + JSON.stringify(data));
}
});
3. 创建 AudioCapturer 音频采集器,准备录音
在第2部授权操作完成后才可进行 AudioCapturer 音频采集器的创建,不然没有权限是会报系统异常的错误。
// 此audioCapturer是写在struct中,自行修改位置
audioCapturer: audio.AudioCapturer | null = null
...
let audioCapturerOptions: audio.AudioCapturerOptions = {
// 音频流信息
streamInfo: {
samplingRate: audio.AudioSamplingRate.SAMPLE_RATE_44100,
channels: audio.AudioChannel.CHANNEL_2,
sampleFormat: audio.AudioSampleFormat.SAMPLE_FORMAT_S16LE,
encodingType: audio.AudioEncodingType.ENCODING_TYPE_RAW
},
// 采集器信息
capturerInfo: {
source: audio.SourceType.SOURCE_TYPE_MIC,
capturerFlags: 0
}
}
// 创建音频采集器
audio.createAudioCapturer(audioCapturerOptions, (err, data) => {
if (err) {
console.error(`luvi > AudioCapturer Created : Error: ${err}`);
} else {
console.info('luvi > AudioCapturer Created : Success.');
// 音频采集器对象
this.audioCapturer = data;
}
});
4. 开始录音
在第3步的操作后,我们已经拿到了 audioCapturer 对象,后续需要通过该对象进行音频录制与取消。
在录音过程中,需要不断的写入声音数据到文件中,所以我们需要订阅音频数据读入回调事件 后触发 start 操作开始录音,在文件数据写入前需要增加 fs.OpenMode.READ_WRITE 权限。此处需要注意的是 MyVoice.wav 文件本身并不存在与沙箱文件中,但是我们使用文件管理的 open 方法配置 fs.OpenMode.CREATE 权限则会自动创建出该文件。
// 导入文件管理模块
import { fileIo as fs, ReadOptions } from '@kit.CoreFileKit';
...
// struct中
destFile: fs.File | null = null
...
Button("开始采集语音").onClick(() => {
let path = getContext().getApplicationContext().filesDir;
let bufferSize: number = 0;
let filePath = path + '/MyVoice.wav';
this.destFile = fs.openSync(filePath, fs.OpenMode.READ_WRITE | fs.OpenMode.READ_ONLY | fs.OpenMode.CREATE | fs.OpenMode.TRUNC);
let readDataCallback = (buffer: ArrayBuffer) => {
let options: ReadOptions = {
offset: bufferSize,
length: buffer.byteLength
}
fs.writeSync(this.destFile?.fd, buffer, options);
bufferSize += buffer.byteLength;
}
this.audioCapturer?.on('readData', readDataCallback);
this.audioCapturer?.start((err: BusinessError) => {
if (err) {
console.error('luvi > Capturer start failed.');
} else {
console.info('luvi > Capturer start success.');
}
});
})
5. 结束录音
录音结束后关闭文件操作,避免资源占用。
Button("结束采集音频").onClick(() => {
this.audioCapturer?.stop((err: BusinessError) => {
if (err) {
console.error('luvi > Capturer stop failed');
} else {
console.info('luvi > Capturer stopped.');
}
});
fs.close(this.destFile)
})
此时录制的音频已经保存至了沙箱中。
6. 创建音频渲染器
audioRenderer 是写在 struct 中,需要保存音频渲染器对象供后续使用。
// 此audioRenderer是写在struct中,自行修改位置
audioRenderer: audio.AudioRenderer| null = null
...
let audioRendererOptions: audio.AudioRendererOptions = {
streamInfo: {
samplingRate: audio.AudioSamplingRate.SAMPLE_RATE_48000, // 采样率
channels: audio.AudioChannel.CHANNEL_2, // 通道
sampleFormat: audio.AudioSampleFormat.SAMPLE_FORMAT_S16LE, // 采样格式
encodingType: audio.AudioEncodingType.ENCODING_TYPE_RAW // 编码格式
},
rendererInfo: {
content: audio.ContentType.CONTENT_TYPE_MUSIC, // 媒体类型
usage: audio.StreamUsage.STREAM_USAGE_MEDIA, // 音频流使用类型
rendererFlags: 0 // 音频渲染器标志
}
}
audio.createAudioRenderer(audioRendererOptions, (err, renderer) => { // 创建AudioRenderer实例
if (!err) {
console.info(`luvi > creating AudioRenderer success`);
// 音频渲染器对象
this.audioRenderer = renderer;
} else {
console.info(`luvi > creating AudioRenderer failed, error: ${err.message}`);
}
});
7.播放音频
播放第5步保存的音频文件,需要使用音频渲染器对象,创建的渲染器本身无音频对象,所以需要在启动音频渲染器后,不断地在音频渲染器中写入音频文件的缓冲数据,从而达到播放效果,当播放完毕后关闭文件和渲染器。
Button("播放音频采集结果").onClick(async () => {
if (!this.audioRenderer){
return
}
let stateGroup = [audio.AudioState.STATE_PREPARED, audio.AudioState.STATE_PAUSED, audio.AudioState.STATE_STOPPED];
if (stateGroup.indexOf(this.audioRenderer.state) === -1) { // 当且仅当状态为prepared、paused和stopped之一时才能启动渲染
console.error('luvi > start failed');
return;
}
await this.audioRenderer.start(); // 启动渲染
const bufferSize = await this.audioRenderer.getBufferSize();
let context = getContext(this).getApplicationContext();
let path = context.filesDir;
const filePath = path + '/MyVoice.wav'; // 使用沙箱路径获取文件,实际路径为/data/storage/el2/base/haps/entry/files/test.wav
let file = fs.openSync(filePath, fs.OpenMode.READ_ONLY);
let stat = await fs.stat(filePath);
let buf = new ArrayBuffer(bufferSize);
let len = stat.size % bufferSize === 0 ? Math.floor(stat.size / bufferSize) : Math.floor(stat.size / bufferSize + 1);
for (let i = 0; i < len; i++) {
let options: ReadOptions = {
offset: i * bufferSize,
length: bufferSize
};
let readsize = await fs.read(file.fd, buf, options);
// buf是要写入缓冲区的音频数据,在调用AudioRenderer.write()方法前可以进行音频数据的预处理,实现个性化的音频播放功能,AudioRenderer会读出写入缓冲区的音频数据进行渲染
let writeSize: number = await new Promise((resolve, reject) => {
this.audioRenderer?.write(buf, (err, writeSize) => {
if (err) {
reject(err);
} else {
resolve(writeSize);
}
});
});
if (this.audioRenderer.state === audio.AudioState.STATE_RELEASED) { // 如果渲染器状态为released,停止渲染
fs.close(file);
await this.audioRenderer.stop();
}
if (this.audioRenderer.state === audio.AudioState.STATE_RUNNING) {
if (i === len - 1) { // 如果音频文件已经被读取完,停止渲染
fs.close(file);
await this.audioRenderer.stop();
}
}
}
})
此时,我们就已经完成了音频录制与播放的一整套功能,若在开发中遇到问题可连接设备点击 IDE 右下角的 Device File Browser 文件浏览器,查看音频文件写入是否正确,还有最重要的就算别忘记添加权限。
完整代码已经提交至了Gitee中,可回顶部查看。