实现NTP实时合唱
更新时间: 2024/11/26 15:44:05
NTP 实时合唱方案通过 NTP 对齐伴奏播放时间,使得双方在在弱网情况下也能精准同步,保证演唱者体验。实时合唱技术避免了合唱者对主唱伴奏的依赖,双方同时起步声音延迟更低。
注意事项
- 默认的麦位数量为 8 。房主和管理员可以手工修改麦位数量。
- 管理员会自动上麦。
前提条件
功能原理
NTP 实时合唱的原理图如下。
NTP 实时合唱的原理说明如下:
- 主唱本地播放伴奏,以辅流方式发送伴奏给 RTC 服务器。
- 合唱者本地播放伴奏,不订阅主唱的伴奏(辅流),也不发送伴奏给 RTC 服务器。
- 主唱和合唱者将自己干声发送给 RTC 服务器。RTC 服务器将伴奏、主唱干声(Audio1)、合唱者干声(Audio2)通过SDK 精准同步混流后,发给观众。
- 主唱能同时听到合唱者的干声(Audio2)。
NTP对齐的原理说明如下:
在实时合唱方案中,需要在开唱后实时同步伴奏进度,避免因伴奏误差而增加端到端延迟。但不同设备的本地时钟并不一致,存在一定误差,因此需要主唱和合唱者通过 NTP 对齐播放伴奏的时间,达到实时的效果,对齐伴奏的原理说明如下:
- 主唱和合唱者分别获取本地系统时间与 RTC 服务端时间的 NTP 时间差。
- 主唱推送本地播放伴奏的开始时间和倒计时时间,并根据主唱的 NTP 时间差,换算成 RTC 服务端的 UTC 时间。
- 合唱者根据 RTC 服务端的 UTC 时间以及合唱者的 NTP 时间差,计算得出本地播放伴奏的 UTC 时间。
合唱准备
在合唱准备阶段,您需要完成以下配置:
设置 NTP 对齐
在实时合唱方案中,需要在开唱后实时同步伴奏进度,避免因伴奏误差而增加端到端延迟。但不同设备的本地时钟并不一致,存在一定误差,因此需要主唱和合唱者通过 NTP 对齐播放伴奏的时间,达到实时的效果。
-
进入房间前,主唱和合唱者调用
setStreamAlignmentProperty
接口,将参数值设置为YES
,设置 NTP 时间对齐。设置成功后,设备会多次与 RTC 服务器校准 NTP 值,该配置能让
getNtpTimeOffset
的值更加精准。示例代码如下:
rtcController?.setStreamAlignmentProperty(true) // 设置对齐本地系统与服务端的时间为 可用
-
开始合唱前,主唱和合唱者分别获取本地系统时间与 RTC 服务端时间的 NTP 时间差。
long timeOffset = rtcController?.getNtpTimeOffset();
-
主唱或合唱者收到开始合唱信令, 主唱本地播放伴奏,并发送播放伴奏信令给合唱者。
例如,主唱播放伴奏的时间设置为 3 秒之后,需要等合唱者同一时间一起播放伴奏。
-
NERoomCreateAudioEffectOption
的startTimeStamp
设置为 UTC时间 + 倒计时时间。long startTimeStamp = System.currentTimeMillis() + 3 * 1000; NERoomCreateAudioEffectOption opt = new NERoomCreateAudioEffectOption(); opt.startTimestamp = startTimeStamp;
-
主唱发送播放伴奏信令给合唱者。信令中包含当前服务器的 UTC 时间 + 倒计时时间。
public void sendChorusSignal() { long localTimeStamp = System.currentTimeMillis(); long ntpOffsetTime = rtcController?.getNtpTimeOffset(); long serverTimeStamp = localTimeStamp - ntpOffsetTime; long serverPlayTimeStamp = serverTimeStamp + 3 * 1000; //伪代码, 发送播放伴奏信令,信令包含serverPlayTimeStamp的值 NERoomKit.getInstance().messageChannelService.sendCustomMessage(currentRoomContext.getRoomUuid(), currentRoomContext.getLocalMember().getUuid(), 0, "hello lcd", (code, message, unit) -> { ALog.d(TAG, "sendPassThroughMessage result: " + code + ", " + message); }); }
-
-
合唱者收到主唱发送的播放伴奏信令,根据播放伴奏的服务器 UTC 时间 + 自己的 NTP 时间差,计算得到自己播放伴奏的 UTC 时间。
//伪代码 override fun onReceiveChatroomMessages(messages: List<NERoomChatMessage>) { long serverPlayTimeStamp = customMessage.getChorusAttchment().getServerPlayTimeStamp(); //服务器正式播放伴音的UTC时间 long ntpOffsetTime = NrtcController?.getNtpTimeOffset(); long localPlayTimeStamp = serverPlayTimeStamp + ntpOffsetTime; //当前设备播放伴音的UTC时间 NERoomCreateAudioEffectOption option = new NERoomCreateAudioEffectOption(); option.startTimestamp = localPlayTimeStamp; }
主唱开启低延时模式
rtcController?.setParameters("engine.audio.ktv.chrous", true)
音频配置
- 在加入房间前调用
setChannelProfile
接口,设置房间场景为直播场景(LIVE_BROADCASTING
)。 - 在加入房间前调用
setLocalAudioProfile
接口,设置音频profile
类型为HIGH_QUALITY_STEREO
,设置scenario
为MUSIC
。 - 设置
MixedAudioFrame
和RecordingAudioFrame
的混流参数。- 调用
channels
接口,设置音频推流声道数量为 2 。 - 调用
sampleRate
接口,设置设备采样率为 48000。
- 调用
示例代码如下:
currentRoomContext?.rtcController?.setLocalAudioProfile(
NERoomRtcAudioProfile.HIGH_QUALITY_STEREO,
NERoomRtcAudioScenario.MUSIC
)
currentRoomContext?.rtcController?.setChannelProfile(NERoomRtcChannelProfile.liveBroadcasting)
val mixRequestFormat = NERoomRtcAudioFrameRequestFormat(
channels = 2,
sampleRate = 48000,
opMode = NERoomRtcAudioFrameOpMode.audioFrameOpModeReadOnly
)
rtcController?.setMixedAudioFrameParameters(mixRequestFormat)
val frameRequestFormat = NERoomRtcAudioFrameRequestFormat(
channels = 2,
sampleRate = 48000,
opMode = NERoomRtcAudioFrameOpMode.audioFrameOpModeReadWrite
)
rtcController?.setRecordingAudioFrameParameters(frameRequestFormat)
主唱和合唱者上麦
-
用户调用
NESeatController.submitSeatRequest
接口申请上麦,成为连麦主播。上麦成功后,房间内所有成员收到
RoomListener#onMemberPropertiesChanged
的回调。如果无空闲麦位,则不允许申请上麦,需要您在业务中自行实现相关逻辑。
currentRoomContext?.seatController?.cancelSeatRequest(callback)
-
查询房间内成员列表,通过成员的
NESeatInfo.seatItems.status
属性,获取已上麦的成员列表。NESeatInfo.seatItems.status
value 的含义说明如下:- 0 : 申请上麦
- 1 : 取消申请上麦
- 2 : 已上麦
- 3 : 拒绝上麦
- 4 : 主动下麦
- 5 : 被踢下麦
private NESeatController controller = NERoomKit.instance.roomService.getRoomContext(initialUuid).seatController;
controller.getSeatInfo(object : NEDataCallback<NESeatInfo>() {
override fun onSuccess(data: NESeatInfo) {
data.seatItems.forEach {
// 进行麦位状态判断
}
}
override fun onError(code: Int, message: String?) {
Toast.makeText(context, getString(R.string.fetch_seat_info_error, code, message), Toast.LENGTH_LONG).show()
}
})
- 添加麦位监听事件。
private NESeatController controller = NERoomKit.instance.roomService.getRoomContext(initialUuid).seatController;
controller.addSeatListener(object : NESeatEventListener() {
override fun onSeatManagerAdded(managers: List<String>) {
Toast.makeText(context, getString(R.string.seat_manager_added, managers), Toast.LENGTH_LONG).show()
}
override fun onSeatManagerRemoved(managers: List<String>) {
Toast.makeText(context, getString(R.string.seat_manager_removed, managers), Toast.LENGTH_LONG).show()
}
override fun onSeatRequestSubmitted(seatIndex: Int, user: String) {
Toast.makeText(context, getString(R.string.seat_request_submitted, user, seatIndex), Toast.LENGTH_LONG).show()
}
override fun onSeatRequestCancelled(seatIndex: Int, user: String) {
Toast.makeText(context, getString(R.string.seat_request_cancelled, user, seatIndex), Toast.LENGTH_LONG).show()
}
override fun onSeatRequestApproved(seatIndex: Int, user: String, operateBy: String) {
Toast.makeText(context, getString(R.string.seat_request_approved, operateBy, user, seatIndex), Toast.LENGTH_LONG).show()
}
override fun onSeatRequestRejected(seatIndex: Int, user: String, operateBy: String) {
Toast.makeText(context, getString(R.string.seat_request_rejected, operateBy, user, seatIndex), Toast.LENGTH_LONG).show()
}
override fun onSeatInvitationReceived(seatIndex: Int, user: String, operateBy: String) {
Toast.makeText(context, getString(R.string.seat_invitation_received, operateBy, user, seatIndex), Toast.LENGTH_LONG).show()
}
override fun onSeatInvitationCancelled(seatIndex: Int, user: String, operateBy: String) {
Toast.makeText(context, getString(R.string.seat_invitation_cancelled, operateBy, user, seatIndex), Toast.LENGTH_LONG).show()
}
override fun onSeatInvitationAccepted(seatIndex: Int, user: String) {
Toast.makeText(context, getString(R.string.seat_invitation_accepted, user, seatIndex), Toast.LENGTH_LONG).show()
}
override fun onSeatInvitationRejected(seatIndex: Int, user: String) {
Toast.makeText(context, getString(R.string.seat_invitation_rejected, user, seatIndex), Toast.LENGTH_LONG).show()
}
override fun onSeatLeave(seatIndex: Int, user: String) {
Toast.makeText(context, getString(R.string.user_leave_seat, user, seatIndex), Toast.LENGTH_LONG).show()
}
override fun onSeatKicked(seatIndex: Int, user: String, operateBy: String) {
Toast.makeText(context, getString(R.string.user_kick_seat, operateBy, user, seatIndex), Toast.LENGTH_LONG).show()
}
override fun onSeatListChanged(seatItems: List<NESeatItem>) {
val itemInfo = seatItems.map {
"${it.index}#${it.user}#${getSeatItemStatusString(it.status)}"
}
Toast.makeText(context, "onSeatListChanged: $itemInfo", Toast.LENGTH_LONG).show()
}
})
点歌
合唱
时序图
实现流程
-
主唱调用点歌的实现方案请参见[点歌台], 接口发起合唱邀请。
-
观众调用
NESeatController.submitSeatRequest
接口申请上麦。上麦成功后,监听
NESeatEventListener.onSeatInvitationAccepted
接口同意合唱。更改成员属性,并业务上设置成员为合唱者的角色。private var controller: NESeatController = NERoomKit.instance.roomService.getRoomContext(initialUuid)!!.seatController controller.addSeatListener(object : NESeatEventListener() { currentRoomContext?.updateMemberProperty(this,MUTE_VOICE_KEY,MUTE_VOICE_VLUE_ON,callback) }
-
主唱和合唱者在开始合唱前,调用如下代码开启 AEC 伴奏模式。
开启 AEC 伴奏模式时,本端的人声保留比较好,有助于演唱者的唱歌体验。
roomContext?.rtcController.setParameters(["key_audio_external_audio_mix": true])
-
合唱者在本地播放伴奏,等合唱者完成下载歌曲文件和歌词后,调用业务接口开始合唱。具体实现方法请参见版权音乐。
-
设置合唱者不订阅主唱的伴奏。
rtcController?.unsubscribeRemoteAudioSubStream(anchorUuid!!)
-
主唱以辅流方式发送伴奏给合唱者和观众。
rtcController?.subscribeRemoteAudioStream("roomUuid") rtcController?.setParameters("engine.audio.ktv.chrous", true) rtcController?.setParameters("key_audio_external_audio_mix", true)
-
主唱调用
sendSEIMsg
接口,发送播放进度给合唱者和观众。val json = JSONObject() json.put("pos", pos) rtcController?.sendSEIMsg(json.toString())
-
主唱和合唱者通过
onAudioEffectTimestampUpdate
同步本地歌词进度。
override fun onAudioEffectTimestampUpdate(uuid: String, timeStampMS: Long) {
// KaraokeLog.i(TAG,"onAudioEffectTimestampUpdate:$timeStampMS")
if (isAnchor()) { // 如果是主唱,要发送SEI消息,同步播放进度。
val json = JSONObject()
json.put("pos", pos)
rtcController?.sendSEIMsg(json.toString())
}
}
- 观众根据的收到
onRtcRecvSEIMsg
的回调,同步歌词进度。private var controller: NESeatController = NERoomKit.instance.roomService.getRoomContext(initialUuid)!!.seatController controller.addSeatListener(object : NESeatEventListener() { currentRoomContext?.onRtcRecvSEIMsg("userUuid","") }
- 主唱调用业务接口结束合唱,关闭低延时,关闭辅流。
// 业务结束合唱 rtcController?.disableLocalSubStreamAudio() // / 关闭主唱音频辅流 rtcController?.subscribeRemoteAudioStream("roomUuid") rtcController?.setParameters("engine.audio.ktv.chrous", false) rtcController?.setParameters("key_audio_external_audio_mix", false) // 关闭AEC模式 roomContext?.rtcController.setParameters(["key_audio_external_audio_mix": false])
- 合唱者调用业务接口结束合唱,关闭低延时,恢复订阅主唱伴奏。
//合唱者关闭低延时模式 rtcController?.setParameters("engine.audio.ktv.chrous", false) rtcController?.setParameters("key_audio_external_audio_mix", false) //合唱者恢复订阅主唱伴奏 rtcController?.subscribeRemoteAudioSubStream(anchorUuid!!) // 关闭AEC模式 roomContext?.rtcController.setParameters(["key_audio_external_audio_mix": false])
进阶功能
主唱、合唱者静音和取消静音
建议通过以下方式静音和取消静音,以免损耗性能。该设置只影响麦克风采集音量,不影响发送的伴奏音量。
NERoomRtcController rtcController = roomContext.getRtcController();
//mute
rtcController.setRecordDeviceMute(true);
//unmute
rtcController.setRecordDeviceMute(false);
切换原唱和伴奏
实现切换播放原唱的示例代码如下:
NERoomRtcController rtcController = roomContext.getRtcController();
//将带原唱的音效音量调整到正常,并将纯伴奏的音量调整成 0
rtcController.setEffectSendVolume(pureEffectId, 0);
rtcController.setEffectPlaybackVolume(pureEffectId, 0);
rtcController.setEffectSendVolume(originEffectId, audioMixVolume);
rtcController.setEffectPlaybackVolume(originEffectId, audioMixVolume);
实现切换为播放伴奏的示例代码如下:
NERoomRtcController rtcController = roomContext.getRtcController();
//将带原唱的音效音量调整到正常,并将纯伴奏的音量调整成 0
rtcController.setEffectSendVolume(pureEffectId, audioMixVolume);
rtcController.setEffectPlaybackVolume(pureEffectId, audioMixVolume);
rtcController.setEffectSendVolume(originEffectId, 0);
rtcController.setEffectPlaybackVolume(originEffectId, 0);