实现NTP实时合唱

更新时间: 2022/11/04 10:02:17

NTP 实时合唱方案通过 NTP 对齐伴奏播放时间,使得双方在在弱网情况下也能精准同步,保证演唱者体验。实时合唱技术避免了合唱者对主唱伴奏的依赖,双方同时起步声音延迟更低。

注意事项

  • 默认的麦位数量为 8 。房主和管理员可以手工修改麦位数量。
  • 管理员会自动上麦。

功能原理

NTP 实时合唱的原理图如下。

NTP实时合唱.png

NTP 实时合唱的原理说明如下:

  1. 主唱本地播放伴奏,以辅流方式发送伴奏给 RTC 服务器。
  2. 合唱者本地播放伴奏,不订阅主唱的伴奏(辅流),也不发送伴奏给 RTC 服务器。
  3. 主唱和合唱者将自己干声发送给 RTC 服务器。RTC 服务器将伴奏、主唱干声(Audio1)、合唱者干声(Audio2)通过SDK 精准同步混流后,发给观众。
  4. 主唱能同时听到合唱者的干声(Audio2)。

NTP对齐的原理说明如下:

在实时合唱方案中,需要在开唱后实时同步伴奏进度,避免因伴奏误差而增加端到端延迟。但不同设备的本地时钟并不一致,存在一定误差,因此需要主唱和合唱者通过 NTP 对齐播放伴奏的时间,达到实时的效果,对齐伴奏的原理说明如下:

  1. 主唱和合唱者分别获取本地系统时间与 RTC 服务端时间的 NTP 时间差。
  2. 主唱推送本地播放伴奏的开始时间和倒计时时间,并根据主唱的 NTP 时间差,换算成 RTC 服务端的 UTC 时间。
  3. 合唱者根据 RTC 服务端的 UTC 时间以及合唱者的 NTP 时间差,计算得出本地播放伴奏的 UTC 时间。

合唱准备

在合唱准备阶段,您需要完成以下配置:

设置 NTP 对齐

在实时合唱方案中,需要在开唱后实时同步伴奏进度,避免因伴奏误差而增加端到端延迟。但不同设备的本地时钟并不一致,存在一定误差,因此需要主唱和合唱者通过 NTP 对齐播放伴奏的时间,达到实时的效果。

  1. 进入房间前,主唱和合唱者调用 setStreamAlignmentProperty 接口,将参数值设置为 YES,设置 NTP 时间对齐。

    设置成功后,设备会多次与 RTC 服务器校准 NTP 值,该配置能让 getNtpTimeOffset 的值更加精准。

    示例代码如下:

    // 加入房间前设置,设置了NTP 精准对齐后,每隔10分钟会重新校验NTP值。
    rtcController?.setStreamAlignmentProperty(true)
    
  2. 开始合唱前,主唱和合唱者分别获取本地系统时间与 RTC 服务端时间的 NTP 时间差。

    let timeOffset = rtcController?.getNtpTimeOffset()
    
  3. 主唱或合唱者收到开始合唱信令, 主唱本地播放伴奏,并发送播放伴奏信令给合唱者。

    例如,主唱播放伴奏的时间设置为 3 秒之后,需要等合唱者同一时间一起播放伴奏。

    1. NERoomCreateAudioEffectOption 的 startTimeStamp 设置为 UTC时间 + 倒计时时间。

      let startTimeStamp = Int64(NSDate().timeIntervalSince1970 * 1000) + 3 * 1000;
      let opt = NERoomCreateAudioEffectOption();
      opt.startTimestamp = startTimeStamp;
      
    2. 主唱发送播放伴奏信令给合唱者。信令中包含当前服务器的 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("发送失败")
      }
      }
      
  4. 合唱者收到主唱发送的播放伴奏信令,根据播放伴奏的服务器 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])

音频配置

  1. 在加入房间前调用 setChannelProfile 接口,设置房间场景为直播场景(liveBroadcasting)。
  2. 在加入房间前调用 setLocalAudioProfile 接口,设置音频 profile 类型为 highQualityStereo,设置 scenariomusic
  3. 设置 MixedAudioFrameRecordingAudioFrame 的混流参数。
    • 调用 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)

主唱和合唱者上麦

  1. 用户调用 NESeatController.submitSeatRequest 接口申请上麦,成为连麦主播。

    上麦成功后,房间内所有成员收到 RoomListener#onMemberPropertiesChanged 的回调。

    如果无空闲麦位,则不允许申请上麦,需要您在业务中自行实现相关逻辑。

  2. 查询房间内成员列表,通过成员的 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 {
                //进行麦位状态判断
            }
        }
    
  3. 添加麦位监听事件。

    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]) {}
    

点歌

  1. 麦位上的用户可以点歌,点歌的实现方案请参见点歌台

  2. 下载歌曲文件和歌词,具体的实现方案请参见歌词展示与同步

合唱

时序图

实时合唱.jpg

实现流程

  1. 主唱发起合唱邀请。具体实现方式需要业务自行实现。
  2. 观众调用 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)
    }
  1. 合唱者在本地播放伴奏,等合唱者完成下载歌曲文件和歌词后,调用业务接口开始合唱。具体实现方法请参见版权音乐
  2. 设置合唱者不订阅主唱的伴奏。
    // 不订阅主唱音频辅流
    roomContext?.rtcController.unsubscribeRemoteAudioSubstream("主唱id")
    
  3. 主唱以辅流方式发送伴奏给合唱者和观众。
    roomContext?.rtcController.enableLocalSubstreamAudio()
    roomContext?.rtcController.setParameters("engine.audio.ktv.chrous", true)
    roomContext?.rtcController.setParameters("key_audio_external_audio_mix", true)
    
  4. 主唱调用 sendSEIMsg 接口,发送播放进度给合唱者和观众。
    let param = ["position": xxxxxxxxxx]
    // 转data
    guard let data = try? JSONSerialization.data(withJSONObject: param, options: []) else {
        return
    }
    rtcController?.sendSEIMsg(data)
    
  5. 主唱和合唱者通过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)
   }
  1. 观众根据的收到 onRtcReciveSEIMessage 的回调,同步歌词进度。
    // 接收SEI信息回调
    func onRtcReciveSEIMessage(_ userUuid: String, message: Data) {}
    
  2. 主唱调用业务接口结束合唱,关闭低延时,关闭辅流
    // 业务结束合唱
    rtcController?.disableLocalSubStreamAudio() // / 关闭主唱音频辅流
    // 音频发送给所有人
    rtcController?.setAudioSubscribeOnlyBy([])
    // 关闭低延时
    rtcController?.setParameters("engine.audio.ktv.chrous", false)
    // 关闭AEC模式
    rtcController?.setParameters("key_audio_external_audio_mix", false)
    
  3. 合唱者调用业务接口结束合唱,关闭低延时,恢复订阅主唱伴奏。
    rtcController?.setAudioSubscribeOnlyBy([])
    // 关闭低延时
    rtcController?.setParameters("engine.audio.ktv.chrous", false)
    // 关闭AEC模式
    rtcController?.setParameters("key_audio_external_audio_mix", false)
    
    //合唱者恢复订阅主唱伴奏
    roomContext?.rtcController.subscribeRemoteAudioSubstream("主唱id")
    
    
    
此文档是否对你有帮助?
有帮助
去反馈
  • 注意事项
  • 功能原理
  • 合唱准备
  • 设置 NTP 对齐
  • 主唱开启低延时模式
  • 音频配置
  • 主唱和合唱者上麦
  • 点歌
  • 合唱