实现纯语音互动
本文介绍如何集成声网实时互动 SDK,通过少量代码从 0 开始实现一个简单的纯语音互动 App,适用于语音通话场景。
首先,你需要了解以下有关音视频实时互动的基础概念:
- 声网实时互动 SDK:由声网开发的、帮助开发者在 App 中实现实时音视频互动的 SDK。
- 频道:用于传输数据的通道,在同一个频道内的用户可以进行实时互动。
- 主播:可以在频道内发布音视频,同时也可以订阅其他主播发布的音视频。
- 观众:可以在频道内订阅音视频,不具备发布音视频权限。
更多概念详见关键概念。
下图展示在 App 中实现纯语音互动的基本工作流程:
- 所有用户调用
joinChannel
方法加入频道,并将所有用户角色都设置为主播。 - 加入频道后,所有用户都可以在频道内发布音频流,并订阅对方的音频流。
前提条件
在实现功能以前,请按照以下要求准备开发环境:
- DevEco Studio NEXT Beta1 及以上版本。
- API Version 11 的 HarmonyOS NEXT SDK 及以上版本。
- API Version 11 的 HarmonyOS NEXT 2.0.0.59 操作系统及以上版本。
- 华为开发者账号。可在华为开发者联盟注册账号。
- 两台支持 HarmonyOS 的设备,建议使用 Mate 60 Pro 及更高性能的设备。
- 可以访问互联网的计算机。如果你的网络环境部署了防火墙,参考应对防火墙限制以正常使用声网服务。
- 一个有效的声网账号以及声网项目。请参考开通服务从声网控制台获得 和临时 。
临时 Token 的有效期是 24 小时。Token 过期会导致加入频道失败。
创建项目
本小节介绍如何创建项目并为项目添加体验实时互动所需的权限。
-
参考创建和运行 Hello World,创建一个新工程。
-
打开 entry/src/main 中的
module.json5
文件,在module
模块中声明网络和设备权限:JSON"requestPermissions": [
// 访问互联网的权限
{
"name": "ohos.permission.INTERNET",
"reason": "$string:Internet_reason",
"usedScene": {
"abilities": [
"EntryAbility"
],
"when": "always" // 始终需要该权限
}
},
// 访问麦克风的权限
{
"name": "ohos.permission.MICROPHONE",
"reason": "$string:Audio_reason",
"usedScene": {
"abilities": [
"EntryAbility"
],
"when": "always" // 始终需要该权限
}
},
] -
打开 src/main/resources/base/element 中的
string.json
文件,添加如下代码,定义所需要的权限。JSON{
"name": "Internet_reason",
"value": "access internet"
},
{
"name": "Audio_reason",
"value": "access audio"
}
集成 SDK
-
在下载页面下载最新版本的鸿蒙音频 SDK,并在本地解压。
-
打开解压文件,将
AgoraRtcSdk.har
文件复制到项目 entry -> libs 目录下(libs 文件夹需要手动创建)。 -
在 entry 下的
oh-pacakge.json5
文件中添加如下依赖,然后点击 Sync 开始同步。JSON"dependencies": {
"@shengwang/rtc-voice":"file:./libs/AgoraRtcSdk.har"
}
实现方法
下图展示了使用声网 RTC SDK 实现纯语音互动的基本流程。
下面列出了一段实现纯语音互动基本流程的完整代码以供参考。复制以下代码到 pages/Index.ets
文件中替换原有内容,即可快速体验纯语音互动。
在 appId
、token
和 channelName
字段中传入你在控制台获取到的 App ID、临时 Token,以及生成临时 Token 时填入的频道名。
import {
ChannelMediaOptions,
Constants,
RtcEngine,
RtcEngineConfig,
RtcStats,
} from "AgoraRtcSdk"
import { common } from '@kit.AbilityKit';
@Entry
@Component
struct Index {
private rtcEngine: RtcEngine | null = null;
@State isJoined: boolean = false;
private channel = "hmostest";
build() {
Flex({ direction: FlexDirection.Column }) {
Row() {
Button("Join")
.enabled(!this.isJoined)
.fontSize(20)
.width("40%")
.margin({ left: "5%", right: "5%" })
.align(Alignment.Center)
.onClick(() => {
this.initEngineAndJoinChannel();
})
Button("Leave")
.enabled(this.isJoined)
.fontSize(20)
.width("40%")
.align(Alignment.Center)
.onClick(() => {
this.rtcEngine!.leaveChannel();
})
.margin({ left: "5%", right: "5%" })
}.align(Alignment.Center)
.margin({ top: "5%", bottom: "5%" })
}
}
initEngineAndJoinChannel() {
if (this.rtcEngine === null) {
let config: RtcEngineConfig = new RtcEngineConfig();
let context = getContext(this) as common.UIAbilityContext;
config.mContext = context;
config.mAppId = "<#Your App ID#>";
config.mEventHandler = {};
config.mEventHandler.onUserJoined = (uid: number, collapse: number) => {
console.info("mEventHandler.onUserJoined: " + uid + " , " + collapse);
};
config.mEventHandler.onUserOffline = (uid: number, reason: number) => {
console.info("mEventHandler.onUserOffline: " + uid + " , " + reason);
};
config.mEventHandler.onJoinChannelSuccess = (cid: string, uid: number, elapsed: number) => {
console.info("mEventHandler.onJoinChannelSuccess: " + cid + " , " + uid);
this.isJoined = true;
};
config.mEventHandler.onLeaveChannel = (stats: RtcStats | null) => {
console.info("mEventHandler.onLeaveChannel ");
this.isJoined = false;
};
this.rtcEngine = RtcEngine.create(config);
}
let option: ChannelMediaOptions = new ChannelMediaOptions();
option.autoSubscribeAudio = true;
option.publishMicrophoneTrack = true;
option.channelProfile = Constants.ChannelProfile.LIVE_BROADCASTING;
option.clientRoleType = Constants.ClientRole.BROADCASTER;
// joinChannelWithOptions(token:string, channelId:string, uid:number, options:ChannelMediaOptions)
// uid 为 0,表示由服务器分配 uid,分配的 uid 将通过 onJoinChannelSuccess 回调返回
this.rtcEngine.joinChannelWithOptions("<#Your Token#>", this.channel, 0, option);
}
aboutToDisappear(): void {
if (this.rtcEngine != null) {
RtcEngine.destroy();
this.rtcEngine = null;
}
}
}
处理权限请求
本小节介绍如何获取设备的录音权限。
-
在 entryablility ->
EntryAbility.ets
中的onCreate
函数中添加权限申请。启动应用程序时,检查是否已在 App 中授予了实现实时互动所需的权限。
ArkTS// 仅保留录音权限
const permissions: Array<Permissions> = ['ohos.permission.INTERNET', 'ohos.permission.MICROPHONE'];
async function checkAccessToken(permission: Permissions): Promise<abilityAccessCtrl.GrantStatus> {
let atManager = abilityAccessCtrl.createAtManager();
let grantStatus: abilityAccessCtrl.GrantStatus = abilityAccessCtrl.GrantStatus.PERMISSION_DENIED;
// 获取应用程序的 accessTokenID
let tokenId: number = 0;
try {
let bundleInfo: bundleManager.BundleInfo = await bundleManager.getBundleInfoForSelf(bundleManager.BundleFlag.GET_BUNDLE_INFO_WITH_APPLICATION);
let appInfo: bundleManager.ApplicationInfo = bundleInfo.appInfo;
tokenId = appInfo.accessTokenId;
} catch (error) {
let err: BusinessError = error as BusinessError;
console.error(`Failed to get bundle info for self. Code is ${err.code}, message is ${err.message}`);
}
// 校验 App 是否被授予权限
try {
grantStatus = await atManager.checkAccessToken(tokenId, permission);
} catch (error) {
let err: BusinessError = error as BusinessError;
console.error(`Failed to check access token. Code is ${err.code}, message is ${err.message}`);
}
return grantStatus;
}
export default class EntryAbility extends UIAbility {
onCreate(want: Want, launchParam: AbilityConstant.LaunchParam) {
hilog.info(0x0000, 'testTag', '%{public}s', 'Ability onCreate');
let context = this.context;
let atManager = abilityAccessCtrl.createAtManager();
// requestPermissionsFromUser 会判断权限的授权状态来决定是否唤起弹窗
for (let i = 0; i < permissions.length; i++) {
let permission: Permissions = permissions[i];
let granted: boolean = false;
let toGrantedPerm: Array<Permissions> = new Array<Permissions>();
toGrantedPerm.push(permission);
checkAccessToken(permission).then((value) => {
if (value == abilityAccessCtrl.GrantStatus.PERMISSION_GRANTED) {
granted = true;
hilog.info(0x0000, 'testTag', '%{public}s', 'Permission granted');
} else {
atManager.requestPermissionsFromUser(context, toGrantedPerm).then((data) => {
let grantStatus: Array<number> = data.authResults;
let length: number = grantStatus.length;
for (let i = 0; i < length; i++) {
if (grantStatus[i] === 0) {
// 用户授权,可以继续访问目标操作
} else {
// 用户拒绝授权,提示用户必须授权才能访问当前页面的功能,并引导用户到系统设置中打开相应的权限
hilog.info(0x0000, 'testTag', '%{public}s', 'Permission denied');
return;
}
}
// 授权成功
}).catch((err: BusinessError) => {
hilog.info(0x0000, 'testTag', '%{public}s', `Failed to request permissions from user. Code is ${err.code}, message is ${err.message}`);
});
}
});
}
}
}
导入声网组件
打开 pages/Index.ets
文件,导入声网 SDK 组件。
import {
ChannelMediaOptions,
Constants,
RtcEngine,
RtcEngineConfig,
RtcStats,
} from "@shengwang/rtc-voice"
初始化引擎
调用 create
方法初始化 RtcEngine
。
在初始化 SDK 前,需确保终端用户已经充分了解并同意相关的隐私政策。
let config:RtcEngineConfig = new RtcEngineConfig();
let context = getContext(this) as common.UIAbilityContext;
config.mContext = context;
// 在这里输入你在声网控制台中获取的 App ID
config.mAppId = "<#Your App ID#>";
config.mEventHandler = {};
// 创建并初始化 RtcEngine
this.rtcEngine = RtcEngine.create(config);
加入频道并发布音频流
调用 joinChannelWithOptions
加入频道。在 options
中进行如下配置:
- 设置频道场景为
BROADCASTING
(直播场景) 并设置用户角色设置为BROADCASTER
(主播)。 - 将
publishMicrophoneTrack
设置为true
,发布麦克风采集的音频。 - 将
autoSubscribeAudio
和autoSubscribeVideo
设置为true
,自动订阅所有音频流。
let option:ChannelMediaOptions = new ChannelMediaOptions();
// 自动订阅所有音频流
option.autoSubscribeAudio = true;
// 发布麦克风采集的音频
option.publishMicrophoneTrack = true;
// 设置频道场景为直播
option.channelProfile = Constants.ChannelProfile.LIVE_BROADCASTING;
// 设置用户角色为主播
option.clientRoleType = Constants.ClientRole.BROADCASTER;
// 使用临时 Token 加入频道,在这里传入你的项目的 Token 和频道名
// uid 为 0,表示由服务器分配 uid,分配的 uid 将通过 onJoinChannelSuccess 回调返回
this.rtcEngine.joinChannelWithOptions(<#Your Token#>, <#Your Channel Name#>, 0,option);
实现常用回调
根据使用场景,定义必要的回调。以下示例代码展示如何实现 onJoinChannelSuccess
和 onUserOffline
回调。
config.mEventHandler.onUserJoined = (uid: number, collapse: number) => {
console.info("mEventHandler.onUserJoined: " + uid + " , " + collapse);
};
config.mEventHandler.onJoinChannelSuccess = (cid: string, uid: number, elapsed: number) => {
console.info("mEventHandler.onJoinChannelSuccess: " + cid + " , " + uid);
};
config.mEventHandler.onUserOffline = (uid: number, reason: number) => {
console.info("mEventHandler.onUserOffline: " + uid + " , " + reason);
};
开始音频互动
在 onClick
中调用一系列方法加载界面布局、检查 App 是否获取实时互动所需权限,并加入频道开始音频互动。
Button("Join")
.enabled(!this.isJoined)
.fontSize(20)
.width("40%")
.margin({ left: "5%", right: "5%" })
.align(Alignment.Center)
.onClick(() => {
this.initEngineAndJoinChannel();
})
结束音频互动
按照以下步骤结束音频互动:
2. 调用 leaveChannel
离开当前频道,释放所有会话相关的资源。
3. 调用 destroy
销毁引擎,并释放声网 SDK 中使用的所有资源。
调用 destroy
后,你将无法再使用 SDK 的所有方法和回调。如需再次使用实时音频互动功能,你必须重新创建一个新的引擎。详见初始化引擎。
this.rtcEngine!.leaveChannel();
RtcEngine.destroy();
this.rtcEngine = null;
测试 App
按照以下步骤测试直播 App:
-
开启鸿蒙 NEXT 设备的开发者选项,打开 USB 调试,通过 USB 连接线将鸿蒙 NEXT 设备接入电脑。
-
在 DevEcho Studio 中,点击 Sync and Refresh Project进行同步。
-
待同步成功后,点击 Run 'app' 开始编译。片刻后,App 便会安装到你的鸿蒙 NEXT 设备上。
-
启动 App,授予录音权限。
-
使用第二台鸿蒙 NEXT 设备,重复以上步骤,在该设备上安装 App、打开 App 加入频道,观察测试结果:双方可以听到彼此的声音。
后续步骤
在完成音频互动后,你可以阅读以下文档进一步了解:
本文的示例使用了临时 Token 加入频道。在测试或生产环境中,为保证通信安全,声网推荐从服务器中获取 Token,详情请参考使用 Token 鉴权。
相关信息
本节提供了额外的信息供参考。
示例项目
声网提供了开源的纯语音互动示例项目供你参考,你可以前往下载或查看其中的源代码。