使用AudioCapturer或OHAudio录制得到的是PCM格式的音频数据,PCM是原始音频数据,不能像MP3、AAC、WAV等音频文件一样直接被普通播放器识别播放。业务侧如果需要通过网络传输、缓存或提交给服务端处理,可以将录制得到的PCM数据转换为Base64字符串。
OHAudio提供音频录制C API,录制获取的是PCM原始音频数据。可以先使用native_audiocapturer.h采集PCM数据,再将PCM二进制数据转换为Base64字符串。下述示例同时提供两条验证链路:Native侧直接将PCM转换为Base64;ArkTS侧读取保存的.pcm文件后再次转换为Base64,用于对比验证转换结果。
"requestPermissions": [
{
"name": "ohos.permission.MICROPHONE",
"reason": "$string:reason",
"usedScene": {
"abilities": [
"EntryAbility"
],
"when": "inuse"
}
}
], target_link_libraries(entry PUBLIC libace_napi.z.so libohaudio.so)。
#include "napi/native_api.h"
#include <cstdint>
#include <fstream>
#include <mutex>
#include <string>
#include <vector>
#include <ohaudio/native_audiocapturer.h>
#include <ohaudio/native_audiostreambuilder.h>
namespace {
OH_AudioCapturer* g_audioCapturer = nullptr;
std::vector<uint8_t> g_pcmData;
std::mutex g_audioMutex;
std::mutex g_pcmMutex;
std::string EncodeBase64(const uint8_t* data, size_t len)
{
static constexpr char BASE64_TABLE[] =
"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
std::string result;
result.reserve(((len + 2) / 3) * 4);
for (size_t i = 0; i < len; i += 3) {
uint32_t value = static_cast<uint32_t>(data[i]) << 16;
if (i + 1 < len) {
value |= static_cast<uint32_t>(data[i + 1]) << 8;
}
if (i + 2 < len) {
value |= static_cast<uint32_t>(data[i + 2]);
}
result.push_back(BASE64_TABLE[(value >> 18) & 0x3F]);
result.push_back(BASE64_TABLE[(value >> 12) & 0x3F]);
result.push_back((i + 1 < len) ? BASE64_TABLE[(value >> 6) & 0x3F] : '=');
result.push_back((i + 2 < len) ? BASE64_TABLE[value & 0x3F] : '=');
}
return result;
}
bool VerifyBase64Encoder()
{
struct Case {
const uint8_t* data;
size_t len;
const char* expected;
};
static const uint8_t oneByte[] = {'M'};
static const uint8_t twoBytes[] = {'M', 'a'};
static const uint8_t threeBytes[] = {'M', 'a', 'n'};
static const uint8_t binary[] = {0x00, 0x01, 0x02, 0x03};
const Case cases[] = {
{nullptr, 0, ""},
{oneByte, sizeof(oneByte), "TQ=="},
{twoBytes, sizeof(twoBytes), "TWE="},
{threeBytes, sizeof(threeBytes), "TWFu"},
{binary, sizeof(binary), "AAECAw=="},
};
for (const auto& item : cases) {
if (EncodeBase64(item.data, item.len) != item.expected) {
return false;
}
}
return true;
}
void OnReadData(OH_AudioCapturer* capturer, void* userData, void* audioData, int32_t audioDataSize)
{
(void)capturer;
(void)userData;
if (audioData == nullptr || audioDataSize <= 0) {
return;
}
auto* begin = static_cast<uint8_t*>(audioData);
std::lock_guard<std::mutex> lock(g_pcmMutex);
g_pcmData.insert(g_pcmData.end(), begin, begin + audioDataSize);
}
bool ConfigureCapturerBuilder(OH_AudioStreamBuilder* builder)
{
return OH_AudioStreamBuilder_SetSamplingRate(builder, 48000) == AUDIOSTREAM_SUCCESS &&
OH_AudioStreamBuilder_SetChannelCount(builder, 1) == AUDIOSTREAM_SUCCESS &&
OH_AudioStreamBuilder_SetSampleFormat(builder, AUDIOSTREAM_SAMPLE_S16LE) == AUDIOSTREAM_SUCCESS &&
OH_AudioStreamBuilder_SetEncodingType(builder, AUDIOSTREAM_ENCODING_TYPE_RAW) == AUDIOSTREAM_SUCCESS &&
OH_AudioStreamBuilder_SetCapturerInfo(builder, AUDIOSTREAM_SOURCE_TYPE_MIC) == AUDIOSTREAM_SUCCESS &&
OH_AudioStreamBuilder_SetCapturerReadDataCallback(builder, OnReadData, nullptr) == AUDIOSTREAM_SUCCESS;
}
bool CreateCapturer()
{
OH_AudioStreamBuilder* builder = nullptr;
if (OH_AudioStreamBuilder_Create(&builder, AUDIOSTREAM_TYPE_CAPTURER) != AUDIOSTREAM_SUCCESS || builder == nullptr) {
return false;
}
if (!ConfigureCapturerBuilder(builder)) {
OH_AudioStreamBuilder_Destroy(builder);
return false;
}
OH_AudioCapturer* capturer = nullptr;
OH_AudioStream_Result result = OH_AudioStreamBuilder_GenerateCapturer(builder, &capturer);
OH_AudioStreamBuilder_Destroy(builder);
if (result != AUDIOSTREAM_SUCCESS || capturer == nullptr) {
return false;
}
g_audioCapturer = capturer;
return true;
}
bool StartRecordInternal()
{
if (!VerifyBase64Encoder()) {
return false;
}
std::lock_guard<std::mutex> audioLock(g_audioMutex);
if (g_audioCapturer != nullptr) {
return false;
}
if (!CreateCapturer()) {
return false;
}
{
std::lock_guard<std::mutex> pcmLock(g_pcmMutex);
g_pcmData.clear();
}
if (OH_AudioCapturer_Start(g_audioCapturer) != AUDIOSTREAM_SUCCESS) {
OH_AudioCapturer_Release(g_audioCapturer);
g_audioCapturer = nullptr;
return false;
}
return true;
}
std::vector<uint8_t> StopRecordAndGetPcmData()
{
OH_AudioCapturer* capturer = nullptr;
{
std::lock_guard<std::mutex> audioLock(g_audioMutex);
capturer = g_audioCapturer;
g_audioCapturer = nullptr;
}
if (capturer == nullptr) {
return {};
}
OH_AudioCapturer_Stop(capturer);
OH_AudioCapturer_Release(capturer);
std::vector<uint8_t> pcmData;
{
std::lock_guard<std::mutex> pcmLock(g_pcmMutex);
pcmData.swap(g_pcmData);
}
return pcmData;
}
bool WritePcmFile(const std::string& filePath, const std::vector<uint8_t>& pcmData)
{
if (filePath.empty() || pcmData.empty()) {
return false;
}
std::ofstream output(filePath, std::ios::binary | std::ios::trunc);
if (!output.is_open()) {
return false;
}
output.write(reinterpret_cast<const char*>(pcmData.data()), static_cast<std::streamsize>(pcmData.size()));
return output.good();
}
bool GetStringArgument(napi_env env, napi_callback_info info, std::string& value)
{
size_t argc = 1;
napi_value args[1] = {nullptr};
napi_get_cb_info(env, info, &argc, args, nullptr, nullptr);
if (argc < 1 || args[0] == nullptr) {
return false;
}
size_t length = 0;
if (napi_get_value_string_utf8(env, args[0], nullptr, 0, &length) != napi_ok) {
return false;
}
std::vector<char> buffer(length + 1);
if (napi_get_value_string_utf8(env, args[0], buffer.data(), buffer.size(), &length) != napi_ok) {
return false;
}
value.assign(buffer.data(), length);
return true;
}
} // namespace
static napi_value StartRecord(napi_env env, napi_callback_info info)
{
(void)info;
napi_value result;
napi_get_boolean(env, StartRecordInternal(), &result);
return result;
}
static napi_value StopRecordToFile(napi_env env, napi_callback_info info)
{
std::string filePath;
if (!GetStringArgument(env, info, filePath)) {
napi_value empty;
napi_create_string_utf8(env, "", NAPI_AUTO_LENGTH, &empty);
return empty;
}
std::vector<uint8_t> pcmData = StopRecordAndGetPcmData();
if (!WritePcmFile(filePath, pcmData)) {
napi_value empty;
napi_create_string_utf8(env, "", NAPI_AUTO_LENGTH, &empty);
return empty;
}
std::string base64 = EncodeBase64(pcmData.data(), pcmData.size());
napi_value result;
napi_create_string_utf8(env, base64.c_str(), base64.size(), &result);
return result;
}
EXTERN_C_START
static napi_value Init(napi_env env, napi_value exports)
{
napi_property_descriptor desc[] = {
{ "startRecord", nullptr, StartRecord, nullptr, nullptr, nullptr, napi_default, nullptr },
{ "stopRecordToFile", nullptr, StopRecordToFile, nullptr, nullptr, nullptr, napi_default, nullptr },
};
napi_define_properties(env, exports, sizeof(desc) / sizeof(desc[0]), desc);
return exports;
}
EXTERN_C_END
static napi_module audioRecorderModule = {
.nm_version = 1,
.nm_flags = 0,
.nm_filename = nullptr,
.nm_register_func = Init,
.nm_modname = "entry",
.nm_priv = nullptr,
.reserved = { 0 },
};
extern "C" __attribute__((constructor)) void RegisterAudioRecorderModule(void)
{
napi_module_register(&audioRecorderModule);
} export const startRecord: () => boolean; export const stopRecordToFile: (path: string) => string;
import audioRecorder from 'libentry.so';
import { abilityAccessCtrl, common, Permissions } from '@kit.AbilityKit';
import { BusinessError } from '@kit.BasicServicesKit';
import { fileIo } from '@kit.CoreFileKit';
const MICROPHONE_PERMISSION: Permissions = 'ohos.permission.MICROPHONE';
const BASE64_CHARS: string = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/';
class PcmToBase64 {
private context: common.UIAbilityContext
constructor(context: common.UIAbilityContext) {
this.context = context
}
private encodeBytes(bytes: Uint8Array, validLength: number): string {
let result: string = ""
for (let i = 0; i < validLength; i += 3) {
const first: number = bytes[i]
const second: number = i + 1 < validLength ? bytes[i + 1] : 0
const third: number = i + 2 < validLength ? bytes[i + 2] : 0
const value: number = (first << 16) | (second << 8) | third
result += BASE64_CHARS.charAt((value >> 18) & 0x3F)
result += BASE64_CHARS.charAt((value >> 12) & 0x3F)
result += i + 1 < validLength ? BASE64_CHARS.charAt((value >> 6) & 0x3F) : '='
result += i + 2 < validLength ? BASE64_CHARS.charAt(value & 0x3F) : '='
}
return result
}
pcmTranslate(pcmFilePath: string): string {
let inputFile: fileIo.File | undefined = undefined
let outputFile: fileIo.File | undefined = undefined
const outputPath: string = `${this.context.filesDir}/pcm_base64_${Date.now()}.txt`
try {
inputFile = fileIo.openSync(pcmFilePath, fileIo.OpenMode.READ_ONLY)
outputFile = fileIo.openSync(outputPath, fileIo.OpenMode.CREATE | fileIo.OpenMode.TRUNC | fileIo.OpenMode.WRITE_ONLY)
let pending: number[] = []
let buffer: ArrayBuffer = new ArrayBuffer(1024 * 256)
let readSize: number = 0
do {
readSize = fileIo.readSync(inputFile.fd, buffer)
if (readSize <= 0) {
break
}
let chunk: Uint8Array = new Uint8Array(buffer, 0, readSize)
if (pending.length > 0) {
let merged: Uint8Array = new Uint8Array(pending.length + readSize)
for (let i = 0; i < pending.length; i++) {
merged[i] = pending[i]
}
merged.set(chunk, pending.length)
chunk = merged
}
const encodeLength: number = Math.floor(chunk.length / 3) * 3
if (encodeLength > 0) {
fileIo.writeSync(outputFile.fd, this.encodeBytes(chunk, encodeLength))
}
pending = []
for (let i = encodeLength; i < chunk.length; i++) {
pending.push(chunk[i])
}
} while (readSize > 0)
if (pending.length > 0) {
fileIo.writeSync(outputFile.fd, this.encodeBytes(new Uint8Array(pending), pending.length))
}
return outputPath
} catch (error) {
console.error(`PCM to Base64 failed: ${JSON.stringify(error)}`)
return ""
} finally {
if (inputFile !== undefined) {
fileIo.closeSync(inputFile)
}
if (outputFile !== undefined) {
fileIo.closeSync(outputFile)
}
}
}
}
@Entry
@Component
struct Index {
@State status: string = "Ready"
@State recordTime: number = 0
@State base64Length: number = 0
@State base64Preview: string = ""
@State pcmPath: string = ""
@State savePath: string = ""
@State pcmByteLength: number = 0
@State canSaveBase64: boolean = false
private base64Data: string = ""
private timerId: number = -1
private buildBase64Preview(base64: string): string {
if (base64.length <= 180) {
return base64
}
const head: string = base64.substring(0, 60)
const middleStart: number = Math.max(0, Math.floor(base64.length / 2) - 30)
const middle: string = base64.substring(middleStart, middleStart + 60)
const tail: string = base64.substring(base64.length - 60)
return `${head}\n...\n${middle}\n...\n${tail}`
}
private clearRecordData(): void {
this.recordTime = 0
this.base64Length = 0
this.base64Preview = ""
this.pcmPath = ""
this.savePath = ""
this.pcmByteLength = 0
this.canSaveBase64 = false
this.base64Data = ""
}
private getAbilityContext(): common.UIAbilityContext {
return this.getUIContext().getHostContext() as common.UIAbilityContext
}
private async requestMicrophonePermission(): Promise<boolean> {
try {
const context = this.getAbilityContext()
const atManager: abilityAccessCtrl.AtManager = abilityAccessCtrl.createAtManager()
const result = await atManager.requestPermissionsFromUser(context, [MICROPHONE_PERMISSION])
return result.authResults.length > 0 &&
result.authResults[0] === abilityAccessCtrl.GrantStatus.PERMISSION_GRANTED
} catch (error) {
const err = error as BusinessError
console.error(`Request microphone permission failed, code: ${err.code}, message: ${err.message}`)
return false
}
}
private async startRecord(): Promise<void> {
const granted: boolean = await this.requestMicrophonePermission()
if (!granted) {
this.status = "Mic Permission Denied"
return
}
let result: boolean = audioRecorder.startRecord()
if (result) {
this.status = "Recording"
this.clearRecordData()
this.timerId = setInterval(() => {
this.recordTime++
}, 1000) as number
} else {
this.status = "Start Failed"
}
}
private saveBase64ToFile(): void {
if (this.base64Data.length === 0) {
this.status = "No Base64 Data"
return
}
const context = this.getAbilityContext()
let filePath: string = `${context.filesDir}/audio_base64_${Date.now()}.txt`
let file: fileIo.File | undefined = undefined
try {
file = fileIo.openSync(filePath, fileIo.OpenMode.READ_WRITE | fileIo.OpenMode.CREATE | fileIo.OpenMode.TRUNC)
fileIo.writeSync(file.fd, this.base64Data)
this.savePath = filePath
this.status = "Saved"
} catch (error) {
this.status = "Save Failed"
console.error(`Save Base64 failed: ${JSON.stringify(error)}`)
} finally {
if (file !== undefined) {
fileIo.closeSync(file)
}
}
}
private pcmToBase64(): void {
if (this.pcmPath.length === 0) {
this.status = "No PCM File"
return
}
const translator = new PcmToBase64(this.getAbilityContext())
const outputPath: string = translator.pcmTranslate(this.pcmPath)
if (outputPath.length === 0) {
this.status = "PCM To Base64 Failed"
return
}
this.savePath = outputPath
this.status = "PCM Base64 Saved"
}
build() {
Scroll() {
Column() {
Text("Audio Recorder Test")
.fontSize(28)
.fontWeight(FontWeight.Bold)
.margin({ bottom: 20 })
Text(`Status: ${this.status}`).fontSize(18).margin({ bottom: 12 })
Text(`Duration: ${this.recordTime} s`).fontSize(18).margin({ bottom: 12 })
Text(`Base64 Length: ${this.base64Length} chars`).fontSize(18).margin({ bottom: 12 })
Text(`PCM Bytes: ${this.pcmByteLength}`).fontSize(18).margin({ bottom: 20 })
if (this.base64Preview.length > 0) {
Text(this.base64Preview)
.fontSize(12)
.maxLines(3)
.textOverflow({ overflow: TextOverflow.Ellipsis })
.backgroundColor("#F5F5F5")
.padding(10)
.width('100%')
.margin({ bottom: 12 })
}
if (this.pcmPath.length > 0) {
Text(`PCM File: ${this.pcmPath}`)
.fontSize(12)
.maxLines(3)
.textOverflow({ overflow: TextOverflow.Ellipsis })
.backgroundColor("#F5F5F5")
.padding(10)
.width('100%')
.margin({ bottom: 12 })
}
if (this.savePath.length > 0) {
Text(`Saved File: ${this.savePath}`)
.fontSize(12)
.maxLines(3)
.textOverflow({ overflow: TextOverflow.Ellipsis })
.backgroundColor("#F5F5F5")
.padding(10)
.width('100%')
.margin({ bottom: 12 })
}
Row({ space: 20 }) {
Button("Start Record")
.width(140)
.height(50)
.enabled(this.status !== "Recording")
.onClick(async () => {
await this.startRecord()
})
Button("Stop Record")
.width(140)
.height(50)
.enabled(this.status === "Recording")
.onClick(() => {
if (this.timerId != -1) {
clearInterval(this.timerId)
this.timerId = -1
}
const context = this.getAbilityContext()
const pcmPath: string = `${context.filesDir}/audio_${Date.now()}.pcm`
let base64: string = audioRecorder.stopRecordToFile(pcmPath)
this.pcmPath = base64.length > 0 ? pcmPath : ""
this.base64Data = base64
this.base64Length = base64.length
this.base64Preview = this.buildBase64Preview(base64)
this.canSaveBase64 = base64.length > 0
this.pcmByteLength = base64.length > 0 ? fileIo.statSync(pcmPath).size : 0
this.status = base64.length > 0 ? "Stopped" : "No Audio Data"
})
}
.margin({ bottom: 20 })
Row({ space: 20 }) {
Button("Save Base64")
.width(140)
.enabled(this.canSaveBase64)
.onClick(() => {
this.saveBase64ToFile()
})
Button("PCM To Base64")
.width(140)
.enabled(this.pcmPath.length > 0)
.onClick(() => {
this.pcmToBase64()
})
}
.margin({ bottom: 15 })
Button("Clear")
.width(120)
.onClick(() => {
this.status = "Ready"
this.clearRecordData()
})
}
.width('100%')
.padding(20)
}
.width('100%')
.height('100%')
}
aboutToDisappear() {
if (this.status === "Recording") {
audioRecorder.stopRecordToFile(`${this.getAbilityContext().filesDir}/audio_${Date.now()}.pcm`)
if (this.timerId != -1) {
clearInterval(this.timerId)
this.timerId = -1
}
}
}
} hdc file recv /data/app/el2/100/base/<bundleName>/haps/entry/files/audio_base64_xxx.txt .
hdc file recv /data/app/el2/100/base/<bundleName>/haps/entry/files/pcm_base64_xxx.txt .
hdc file recv /data/app/el2/100/base/<bundleName>/haps/entry/files/audio_xxx.pcm .
cmp audio_base64_xxx.txt pcm_base64_xxx.txt
base64 -D -i pcm_base64_xxx.txt -o decoded.pcm
cmp audio_xxx.pcm decoded.pcm
参数 | 取值 |
|---|---|
采样率 | 48000 Hz |
通道数 | 单声道 |
采样格式 | S16LE |
编码类型 | PCM RAW |