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

实现串行合唱

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

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

注意事项

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

前提条件

已实现加入和离开房间。

功能原理

串行合唱的原理图如下。

串行合唱.png

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

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

合唱准备

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

音频配置

  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 的回调。

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

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

合唱

时序图

串行合唱.jpg

实现流程

  1. 主唱发起合唱邀请,具体实现方式需要业务自行实现。
  • 业务服务器可通过IM聊天室消息发送发送自定义消息,并在 NERoom 中监听这些自定义消息。
  • 业务方也可通过 NERoom 的 NEMessageChannelService 服务提供的自定义消息通道发送自定义消息。
  1. 观众调用 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)
    }
    
    
  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([])
  1. 合唱者结束合唱。
    // 关闭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)
此文档是否对你有帮助?
有帮助
去反馈
  • 注意事项
  • 前提条件
  • 功能原理
  • 合唱准备
  • 音频配置
  • 主唱和合唱者上麦
  • 合唱
  • 进阶功能
  • 主唱、合唱者静音和取消静音
  • 切换原唱和伴奏