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

实现串行合唱

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

串行合唱场景中,主唱将伴奏和人声发送给合唱者,合唱者根据主唱的伴奏进行合唱,并混流发给观众。

前提条件

已实现加入和离开房间。

功能原理

串行合唱的原理图如下。

串行合唱.png

串行合唱的原理说明如下:

  1. 主唱播放伴奏,并以主流方式发送伴奏。
  2. 主唱的 NERTC SDK 将主唱的干声(Audio1)和伴奏合流后发给合唱者,主唱的干声(Audio1)和伴奏只有合唱者才能听到,主唱不订阅合唱者的干声。
  3. 合唱者将伴奏、主唱的干声(Audio1)和合唱者自己的干声(Audio2)混音后发给观众。
  4. 观众只听合唱者的声音。

合唱准备

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

音频配置

  1. 在加入 NERoom 房间前调用 setChannelProfile 接口,设置房间场景为直播场景(LiveBroadcasting)。

  2. 在加入 NERoom 房间前调用 setLocalAudioProfile 接口,设置音频 profile 类型为 HIGH_QUALITY_STEREO,设置 scenarioMUSIC

  3. 设置 MixedAudioFrameRecordingAudioFrame 的混流参数。

    • 设置音频推流声道数量为 2 。
    • 设置设备采样率为 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. 歌词展示与同步,具体实现逻辑实现需要业务自行实现。

合唱

时序图

Android串行合唱时序图.png

实现流程

  1. 主唱发起合唱邀请,具体实现方式需要业务自行实现。
  • 业务服务器可通过IM聊天室消息发送发送自定义消息,并在 NERoom 中监听这些自定义消息。
  • 业务方也可通过 NERoom 的 NEMessageChannelService 服务提供的自定义消息通道发送自定义消息。
  1. 观众同意合唱,并通过NESeatController提供的接口申请上麦。上麦成功后,调用 unmuteMyAudio 接口开启本地音频采集和发送。

    // 申请上麦
    NERoomService roomService = NERoomKit.getInstance().getService(NERoomService.class);
    NERoomContext roomContext = roomService.getRoomContext(roomUuid);
    if (roomContext!=null){
        NESeatController seatController = roomContext.getSeatController();
        seatController.addSeatListener(new NESeatEventListener() {
            // 其他回调方法
            // ...
        
            @Override
            public void onSeatListChanged(@NonNull List<NESeatItem> seatItems) {
                // 麦位列表状态变更
                boolean isCurrentOnSeat = isCurrentUserOnSeat(seatItems);
                if (isCurrentOnSeat){
                    roomContext.getRtcController().unmuteMyAudio(null);
                }
                roomContext.getRtcController().setClientRole(
                     isCurrentOnSeat ? NERoomRtcClientRole.BROADCASTER : NERoomRtcClientRole.AUDIENCE
                );
            }
        });
    
        seatController.submitSeatRequest(new NECallback2<Unit>() {
            @Override
            public void onSuccess(@Nullable Unit data) {
                // 申请上麦操作成功
            }
    
            @Override
            public void onError(int code, @Nullable String message) {
                // 申请上麦操作失败
            }
        });
    }
    
    private boolean isCurrentUserOnSeat(List<NESeatItem> itemList) {
        for (NESeatItem item : itemList) {
            if (item.getStatus() == NESeatItemStatus.TAKEN 
                    && currentUserId.equals(item.getUser())) {
                return true;
            }
        }
        return false;
    }
    
    
  2. 主唱和合唱者在开始合唱前,调用如下代码开启 AEC 伴奏模式。

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

    roomContext?.rtcController.setParameters(["key_audio_external_audio_mix": true])
    
  3. 唱歌开始前,设置主唱不订阅合唱者的声音,并且自己的声音只发给合唱者。

    roomContext?.rtcController.unsubscribeRemoteAudio(userUuid: choristerId)
    roomContext?.rtcController.setAudioSubscribeOnlyBy([choristerId])
    
  4. 主唱调用 playEffect接口

        let  timestamp = Int64(NSDate().timeIntervalSince1970 * 1000);
        // 原唱
        let oOption = NERoomCreateAudioEffectOption()
        oOption.startTimeStamp = timestamp
        oOption.path = orginalPath
        oOption.playbackVolume = 0
        oOption.sendVolume = 0
        oOption.sendEnabled = sendEnable
        oOption.sendWithAudioType = type
        roomContext.rtcController.playEffect(effectId: originalId, option: oOption)
        // 伴奏
        let aOption = NERoomCreateAudioEffectOption()
        aOption.startTimeStamp = timestamp
        aOption.path = accompanyPath
        aOption.playbackVolume = volume
        aOption.sendVolume = volume
        aOption.sendEnabled = sendEnable
        aOption.sendWithAudioType = type
        roomContext.rtcController.playEffect(effectId: accompanyId, option: aOption)
    
  5. 主唱通过NERoomListener收到伴奏播放进度回调,并将自己的伴奏进度通过 SEI 发送出去。

        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)
        }
    
  6. 合唱者混音。

    
    //只有串行合唱模式下的合唱者,才需要去混音
    func onPlaybackAudioFrameBeforeMixing(withUserId userId: String, audioFrame: NERoomRtcAudioFrame) {
        // 副唱、串行合唱
        guard singer == .chorister, mode == .seaialChorus else { return }
        // 判断主唱的UserId
        guard userId == anchorId else { return }
        let size = audioFrame.format.channels * audioFrame.format.bytesPerSample * audioFrame.format.samplesPerChannel
        NEConversion.shared().circularBufferProduceBytes(withSrc: audioFrame.data, len: Int32(size))
        audioChannels = audioFrame.format.channels
        samplesPerChannel = audioFrame.format.samplesPerChannel
        
        // 缓存5帧 然后处理
        if !beforeStartMix, NEKaraokeConversion.shared().bufferFillCount() >= 5 * size {
            beforeStartMix = true
        }
    }
    
    //只有串行合唱模式下的合唱者,才需要去混音
    func onRecordAudioFrame(_ audioFrame: NERoomRtcAudioFrame) {
        // 合唱者、串行合唱、是否混音前处理
        guard singer == .chorister,
            mode == .seaialChorus,
            beforeStartMix else { return }
        // 音频声道数、每个声道的采样点数 对比
        guard audioChannels == audioFrame.format.channels,
            samplesPerChannel == audioFrame.format.samplesPerChannel else { return }
        var availableBytes: UInt32 = 0
        let size = audioFrame.format.channels * audioFrame.format.bytesPerSample * audioFrame.format.samplesPerChannel
        // 取地址
        let p = withUnsafeMutablePointer(to: &availableBytes) { ptr in return ptr }
        let buffer = NEConversion.shared().circularBufferTail(withAvailableBytes: p)
        if availableBytes >= size {
            PlayerAudioMixer.mixAudioFrameData(audioFrame.data.bindMemory(to: Int16.self, capacity: 1),
                                               data2: buffer.bindMemory(to: Int16.self, capacity: 1),
                                               samplesPerChannel: Int32(audioFrame.format.samplesPerChannel),
                                               channels: Int32(audioFrame.format.channels))
            NEConversion.shared().circularBufferConsume(withAmount: Int32(size))
        }
    }
    
    
  7. 合唱者和观众同步歌词。

    1. 合唱者收到主唱的 SEI,同步自己的歌词进度,再通过 SEI 发送自己的歌词进度。
    2. 观众收到合唱者的 SEI,同步自己的歌词进度。
    func onRtcReciveSEIMessage(_ userUuid: String, message: Data) {
        // 副唱收到主唱的 SEI 同步自己的歌词,并发送自己的歌词进度
        if singer == .chorister {
            roomContext?.rtcController.sendSEIMsg(message)
            // 处理歌词进度
        } else if singer == .audience { // 观众中接收到
            // 处理歌词进度
        }
        
    }
    
  8. 主唱结束合唱。

    // 关闭AEC模式
    roomContext?.rtcController.setParameters(["key_audio_external_audio_mix": false])
    //恢复订阅副唱音频
    roomContext?.rtcController.subscribeRemoteAudio(userUuid: chorusId)
    //黑名单置空,恢复听众听到主唱声音
    roomContext?.rtcController.setAudioSubscribeOnlyBy([])
    
  9. 合唱者结束合唱。

    // 关闭AEC模式
    roomContext?.rtcController.setParameters(["key_audio_external_audio_mix": false])
    

进阶功能

主唱、合唱者静音和取消静音

建议通过以下方式静音和取消静音,以免损耗性能。该设置只影响麦克风采集音量,不影响发送的伴奏音量。

//mute
rtcController.setRecordDeviceMute(muted: true)
//unmute
rtcController.setRecordDeviceMute(muted: false)

切换原唱和伴奏

实现切换播放原唱的示例代码如下:

//将带原唱的音效音量调整到正常,并将纯伴奏的音量调整成 0
rtcController.setEffectSendVolume(pureEffectId, 0)
rtcController.setEffectPlaybackVolume(pureEffectId, 0)
rtcController.setEffectSendVolume(originEffectId, audioMixVolume)
rtcController.setEffectPlaybackVolume(originEffectId, audioMixVolume)

实现切换为播放伴奏的示例代码如下:

//将带原唱的音效音量调整到正常,并将纯伴奏的音量调整成 0
rtcController.setEffectSendVolume(pureEffectId, audioMixVolume)
rtcController.setEffectPlaybackVolume(pureEffectId, audioMixVolume)
rtcController.setEffectSendVolume(originEffectId, 0)
rtcController.setEffectPlaybackVolume(originEffectId, 0)
此文档是否对你有帮助?
有帮助
去反馈
  • 前提条件
  • 功能原理
  • 合唱准备
  • 音频配置
  • 主唱和合唱者上麦
  • 点歌
  • 合唱
  • 进阶功能
  • 主唱、合唱者静音和取消静音
  • 切换原唱和伴奏