实现串行合唱
更新时间: 2024/11/26 15:44:05
串行合唱场景中,主唱将伴奏和人声发送给合唱者,合唱者根据主唱的伴奏进行合唱,并混流发给观众。
注意事项
- 默认的麦位数量为 8 。房主和管理员可以手工修改麦位数量。
- 管理员会自动上麦。
前提条件
功能原理
串行合唱的原理图如下。
串行合唱的原理说明如下:
- 主唱播放伴奏,并以主流方式发送伴奏。
- 主唱的 NERTC SDK 将主唱的干声(Audio1)和伴奏合流后发给合唱者,主唱的干声(Audio1)和伴奏只有合唱者才能听到,主唱不订阅合唱者的干声。
- 合唱者将伴奏、主唱的干声(Audio1)和合唱者自己的干声(Audio2)混音后发给观众。
- 观众只听合唱者的声音。
合唱准备
在合唱准备阶段,您需要完成以下配置:
音频配置
- 在加入房间前调用
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
的回调。如果无空闲麦位,则不允许申请上麦,需要您在业务中自行实现相关逻辑。
currentRoomContext?.seatController?.cancelSeatRequest(callback)
-
查询房间内成员列表,通过成员的
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]) {}
合唱
时序图
实现流程
- 主唱发起合唱邀请,具体实现方式需要业务自行实现。
- 业务服务器可通过IM聊天室消息发送发送自定义消息,并在 NERoom 中监听这些自定义消息。
- 业务方也可通过 NERoom 的
NEMessageChannelService
服务提供的自定义消息通道发送自定义消息。
-
观众调用 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) }
-
主唱和合唱者在开始合唱前,调用如下代码开启 AEC 伴奏模式。
开启 AEC 伴奏模式时,本端的人声保留比较好,有助于演唱者的唱歌体验。
roomContext?.rtcController.setParameters(["key_audio_external_audio_mix": true])
-
唱歌开始前,设置主唱不订阅合唱者的声音,并且自己的声音只发给合唱者。
roomContext?.rtcController.unsubscribeRemoteAudio(userUuid: choristerId) roomContext?.rtcController.setAudioSubscribeOnlyBy([choristerId])
-
主唱调用
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)
-
主唱通过
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) }
-
合唱者混音。
//只有串行合唱模式下的合唱者,才需要去混音 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)) } }
-
合唱者和观众同步歌词。
- 合唱者收到主唱的 SEI,同步自己的歌词进度,再通过 SEI 发送自己的歌词进度。
- 观众收到合唱者的 SEI,同步自己的歌词进度。
func onRtcReciveSEIMessage(_ userUuid: String, message: Data) { // 副唱收到主唱的 SEI 同步自己的歌词,并发送自己的歌词进度 if singer == .chorister { roomContext?.rtcController.sendSEIMsg(message) // 处理歌词进度 } else if singer == .audience { // 观众中接收到 // 处理歌词进度 } }
-
主唱结束合唱。
// 关闭AEC模式
roomContext?.rtcController.setParameters(["key_audio_external_audio_mix": false])
//恢复订阅副唱音频
roomContext?.rtcController.subscribeRemoteAudio(userUuid: chorusId)
//黑名单置空,恢复听众听到主唱声音
roomContext?.rtcController.setAudioSubscribeOnlyBy([])
- 合唱者结束合唱。
// 关闭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)