输入关键词搜索,支持 AI 答疑

实现NTP实时合唱

更新时间: 2024/11/26 15:44:05

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

注意事项

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

前提条件

已实现加入和离开 RTC 房间。

功能原理

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 的值更加精准。

    示例代码如下:

     rtcController?.setStreamAlignmentProperty(true) // 设置对齐本地系统与服务端的时间为 可用
    
  2. 开始合唱前,主唱和合唱者分别获取本地系统时间与 RTC 服务端时间的 NTP 时间差。

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

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

    1. NERoomCreateAudioEffectOptionstartTimeStamp 设置为 UTC时间 + 倒计时时间。

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

音频配置

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

主唱和合唱者上麦

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

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

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

    currentRoomContext?.seatController?.cancelSeatRequest(callback)
  1. 查询房间内成员列表,通过成员的 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()
                    }
                })
  1. 添加麦位监听事件。
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()
            }
        })

点歌

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

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

合唱

时序图

实时合唱.jpg

实现流程

  1. 主唱调用点歌的实现方案请参见[点歌台], 接口发起合唱邀请。

  2. 观众调用 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)
    }
    
  3. 主唱和合唱者在开始合唱前,调用如下代码开启 AEC 伴奏模式。

    开启 AEC 伴奏模式时,本端的人声保留比较好,有助于演唱者的唱歌体验。

    roomContext?.rtcController.setParameters(["key_audio_external_audio_mix": true])
    
  4. 合唱者在本地播放伴奏,等合唱者完成下载歌曲文件和歌词后,调用业务接口开始合唱。具体实现方法请参见版权音乐

  5. 设置合唱者不订阅主唱的伴奏。

     rtcController?.unsubscribeRemoteAudioSubStream(anchorUuid!!)
    
  6. 主唱以辅流方式发送伴奏给合唱者和观众。

    rtcController?.subscribeRemoteAudioStream("roomUuid")
    rtcController?.setParameters("engine.audio.ktv.chrous", true)
    rtcController?.setParameters("key_audio_external_audio_mix", true)
    
  7. 主唱调用 sendSEIMsg 接口,发送播放进度给合唱者和观众。

    val json = JSONObject()
    json.put("pos", pos)
    rtcController?.sendSEIMsg(json.toString())
    
  8. 主唱和合唱者通过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())
        }

    }
  1. 观众根据的收到 onRtcRecvSEIMsg 的回调,同步歌词进度。
    private var controller: NESeatController = NERoomKit.instance.roomService.getRoomContext(initialUuid)!!.seatController
    controller.addSeatListener(object : NESeatEventListener() {
        currentRoomContext?.onRtcRecvSEIMsg("userUuid","")
    }
    
  2. 主唱调用业务接口结束合唱,关闭低延时,关闭辅流。
    // 业务结束合唱
    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])
    
  3. 合唱者调用业务接口结束合唱,关闭低延时,恢复订阅主唱伴奏。
    //合唱者关闭低延时模式
    
    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);

此文档是否对你有帮助?
有帮助
去反馈
  • 注意事项
  • 前提条件
  • 功能原理
  • 合唱准备
  • 设置 NTP 对齐
  • 主唱开启低延时模式
  • 音频配置
  • 主唱和合唱者上麦
  • 点歌
  • 合唱
  • 进阶功能
  • 主唱、合唱者静音和取消静音
  • 切换原唱和伴奏