实现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
的值更加精准。示例代码如下:
// 加入房间前设置,设置了NTP 精准对齐后,每隔10分钟会重新校验NTP值。 rtcController?.setStreamAlignmentProperty(true)
-
开始合唱前,主唱和合唱者分别获取本地系统时间与 RTC 服务端时间的 NTP 时间差。
let timeOffset = rtcController?.getNtpTimeOffset()
-
主唱或合唱者收到开始合唱信令, 主唱本地播放伴奏,并发送播放伴奏信令给合唱者。
例如,主唱播放伴奏的时间设置为 3 秒之后,需要等合唱者同一时间一起播放伴奏。
-
NERoomCreateAudioEffectOption 的 startTimeStamp 设置为 UTC时间 + 倒计时时间。
let startTimeStamp = Int64(NSDate().timeIntervalSince1970 * 1000) + 3 * 1000; let opt = NERoomCreateAudioEffectOption(); opt.startTimestamp = startTimeStamp;
-
主唱发送播放伴奏信令给合唱者。信令中包含当前服务器的 UTC 时间 + 倒计时时间。
- (void)sendChorusSignal { let localTimeStamp = Int64(NSDate().timeIntervalSince1970 * 1000); let ntpTimestamp = roomContext.rtcController.getNtpTimeOffset() let time = localTime - ntpTimestamp //当前服务器的UTC时间 let serverPlayTimeStamp = time + 3 * 1000; //正式开始播放伴奏的服务器 UTC时间 // 指定合唱者发送消息 NERoomKit.shared().messageChannelService.sendCustomMessage(roomUuid: "房间Id", userUuid: "合唱者id", commandId: 10001, // 自定义编号 10000~19999之间 data: "包含utp时间的json字符串") { code, msg, _ in if code == 0 { print("发送成功") } else { print("发送失败") } }
-
-
合唱者收到主唱发送的播放伴奏信令,根据播放伴奏的服务器 UTC 时间 + 自己的 NTP 时间差,计算得到自己播放伴奏的 UTC 时间。
func onReceiveCustomMessage(message: NECustomMessage) { if message.commandId == 10001 { // 自定义的编号 let serverPlayTimeStamp = Int64(message.data) let ntpTimestamp = rtcController?.getNtpTimeOffset() let localTimestamp = ntpTimestamp + serverPlayTimeStamp let opt = NERoomCreateAudioEffectOption(); opt.startTimestamp = localPlayTimeStamp; } }
主唱开启低延时模式
rtcController?.setParameters(["engine.audio.ktv.chrous": true])
音频配置
- 在加入房间前调用
setChannelProfile
接口,设置房间场景为直播场景(liveBroadcasting
)。 - 在加入房间前调用
setLocalAudioProfile
接口,设置音频profile
类型为highQualityStereo
,设置scenario
为music
。 - 设置
MixedAudioFrame
和RecordingAudioFrame
的混流参数。- 调用
channels
接口,设置音频推流声道数量为 2 。 - 调用
sampleRate
接口,设置设备采样率为 48000。
- 调用
示例代码如下:
// 会前 设置场景
roomContext?.rtcController.setChannelProfile(.liveBroadcasting)
/// 设置音频编码属性
roomContext?.rtcController.setAudioProfile(.highQualityStereo, scenario: .music)
// 设置录制和播放声音混音后的数据格式
let format = NERoomRtcAudioFrameRequestFormat()
format.channels = 2
format.sampleRate = 48000
format.mode = .readonly
roomContext?.rtcController.setMixedAudioFrameParameters(format)
// 设置采集的音频格式
let recordFormat = NERoomRtcAudioFrameRequestFormat()
recordFormat.channels = 2
recordFormat.sampleRate = 48000
recordFormat.mode = .readwrite
roomContext?.rtcController.setRecordingAudioFrameParameters(recordFormat)
主唱和合唱者上麦
-
用户调用
NESeatController.submitSeatRequest
接口申请上麦,成为连麦主播。上麦成功后,房间内所有成员收到
RoomListener#onMemberPropertiesChanged
的回调。如果无空闲麦位,则不允许申请上麦,需要您在业务中自行实现相关逻辑。
-
查询房间内成员列表,通过成员的
NESeatInfo.seatItems.status
属性,获取已上麦的成员列表。NESeatInfo.seatItems.status
value 的含义说明如下:- 0 : 申请上麦
- 1 : 取消申请上麦
- 2 : 已上麦
- 3 : 拒绝上麦
- 4 : 主动下麦
- 5 : 被踢下麦
let seatController = NERoomKit.shared().roomService.getRoomContext(roomUuid: "房间id")?.seatController seatController?.getSeatInfo { code, msg, seatInfo in guard let seatInfo = seatInfo else { return } for seatItem in seatInfo.seatItems { //进行麦位状态判断 } }
-
添加麦位监听事件。
let seatController = NERoomKit.shared().roomService.getRoomContext(roomUuid: "")?.seatController seatController?.addSeatListener(self) // 麦位变换会触发 NESeatEventListener协议的 事件, 如下: /** * 麦位管理员新增。 * @param managers 新增的麦位管理员列表。 */ func onSeatManagerAdded(_ managers: [String]) {} /** * 麦位管理员移除。 * @param managers 移除的麦位管理员列表。 */ func onSeatManagerRemoved(_ managers: [String]) {} /** * 成员[user]提交了位置为[seatIndex]的麦位申请。 * @param seatIndex 麦位位置,**-1**表示未指定位置。 * @param user 申请人的用户ID。 */ func onSeatRequestSubmitted(_ seatIndex: Int, user: String) {} /** * 成员[user]取消了位置为[seatIndex]的麦位申请。 * @param seatIndex 麦位位置,**-1**表示未指定位置。 * @param user 申请人的用户ID。 */ func onSeatRequestCancelled(_ seatIndex: Int, user: String) {} /** * 管理员通过了成员[user]的麦位申请,位置为[seatIndex]。 * @param seatIndex 麦位位置。 * @param user 申请人的用户ID。 * @param operateBy 同意该申请的用户ID */ func onSeatRequestApproved(_ seatIndex: Int, user: String, operateBy: String) {} /** * 管理员拒绝了成员[user]的麦位申请,位置为[seatIndex]。 * @param seatIndex 麦位位置,**-1**表示未指定位置。 * @param user 申请人的用户ID。 * @param operateBy 拒绝该申请的用户ID */ func onSeatRequestRejected(_ seatIndex: Int, user: String, operateBy: String) {} /** * 当前成员收到了来自[inviter]的上麦邀请,位置为[seatIndex]。 * @param seatIndex 麦位位置,**-1**表示未指定位置。 * @param user 邀请人。 * @param operateBy 操作人 */ func onSeatInvitationReceived(_ seatIndex: Int, user: String, operateBy: String) {} /** * [inviter]取消了对当前成员的上麦邀请,位置为[seatIndex]。 * @param seatIndex 麦位位置,**-1**表示未指定位置。 * @param user 邀请人。 * @param operateBy 操作人 */ func onSeatInvitationCancelled(_ seatIndex: Int, user: String, operateBy: String) {} /** * 成员[invitee]接受了位置为[seatIndex]的上麦邀请。 * @param seatIndex 麦位位置。 * @param user 被邀请人。 */ func onSeatInvitationAccepted(_ seatIndex: Int, user: String) {} /** * 成员[invitee]拒绝了位置为[seatIndex]的上麦邀请。 * @param seatIndex 麦位位置,**-1**表示未指定位置。 * @param user 被邀请人。 */ func onSeatInvitationRejected(_ seatIndex: Int, user: String) {} /** * 成员下麦,位置为[seatIndex]。 * @param seatIndex 麦位位置。 * @param user 下麦成员。 */ func onSeatLeave(_ seatIndex: Int, user: String) {} /** * 成员[user]被管理员从位置为[seatIndex]的麦位踢掉。 * @param seatIndex 麦位位置。 * @param user 成员。 * @param operateBy 操作人 */ func onSeatKicked(_ seatIndex: Int, user: String, operateBy: String) {} /** * 麦位全量列表变更。 * @param seatItems 麦位列表。 */ func onSeatListChanged(_ seatItems: [NESeatItem]) {}
点歌
合唱
时序图
实现流程
- 主唱发起合唱邀请。具体实现方式需要业务自行实现。
- 观众调用
NESeatController.submitSeatRequest
接口申请上麦。上麦成功后,监听NESeatEventListener.onSeatInvitationAccepted
接口同意合唱,更改成员属性,并业务上设置为合唱者的角色。
// 在麦位列表变更回调中设置 clientRole
func onSeatListChanged(_ seatItems: [NESeatItem]) {
var isOnSeat = false
for item in seatItems {
if item.user == context.localMember.uuid {
isOnSeat = true
}
}
rtcController?.setClientRole( isOnSeat ? .broadcaster : .audience)
}
- 合唱者在本地播放伴奏,等合唱者完成下载歌曲文件和歌词后,调用业务接口开始合唱。具体实现方法请参见版权音乐。
- 设置合唱者不订阅主唱的伴奏。
// 不订阅主唱音频辅流 roomContext?.rtcController.unsubscribeRemoteAudioSubstream("主唱id")
- 主唱以辅流方式发送伴奏给合唱者和观众。
roomContext?.rtcController.enableLocalSubstreamAudio() roomContext?.rtcController.setParameters("engine.audio.ktv.chrous", true) roomContext?.rtcController.setParameters("key_audio_external_audio_mix", true)
- 主唱调用
sendSEIMsg
接口,发送播放进度给合唱者和观众。let param = ["position": xxxxxxxxxx] // 转data guard let data = try? JSONSerialization.data(withJSONObject: param, options: []) else { return } rtcController?.sendSEIMsg(data)
- 主唱和合唱者通过
onRtcAudioEffectTimestampUpdate
同步本地歌词进度。
func onRtcAudioEffectTimestampUpdate(effectId: UInt32, timeStampMS: UInt64) {
let param = [
"pos": timeStampMS
]
guard let data = try? JSONSerialization.data(withJSONObject: param, options: []) else {
return
}
roomContext?.rtcController.sendSEIMsg(data)
}
- 观众根据的收到
onRtcReciveSEIMessage
的回调,同步歌词进度。// 接收SEI信息回调 func onRtcReciveSEIMessage(_ userUuid: String, message: Data) {}
- 主唱调用业务接口结束合唱,关闭低延时,关闭辅流
// 业务结束合唱 rtcController?.disableLocalSubStreamAudio() // / 关闭主唱音频辅流 // 音频发送给所有人 rtcController?.setAudioSubscribeOnlyBy([]) // 关闭低延时 rtcController?.setParameters("engine.audio.ktv.chrous", false) // 关闭AEC模式 rtcController?.setParameters("key_audio_external_audio_mix", false)
- 合唱者调用业务接口结束合唱,关闭低延时,恢复订阅主唱伴奏。
rtcController?.setAudioSubscribeOnlyBy([]) // 关闭低延时 rtcController?.setParameters("engine.audio.ktv.chrous", false) // 关闭AEC模式 rtcController?.setParameters("key_audio_external_audio_mix", false) //合唱者恢复订阅主唱伴奏 roomContext?.rtcController.subscribeRemoteAudioSubstream("主唱id")