实现串行合唱
更新时间: 2024/11/26 15:44:05
串行合唱场景中,主唱将伴奏和人声发送给合唱者,合唱者根据主唱的伴奏进行合唱,并混流发给观众。
前提条件
功能原理
串行合唱的原理图如下。
串行合唱的原理说明如下:
- 主唱播放伴奏,并以主流方式发送伴奏。
- 主唱的 NERTC SDK 将主唱的干声(Audio1)和伴奏合流后发给合唱者,主唱的干声(Audio1)和伴奏只有合唱者才能听到,主唱不订阅合唱者的干声。
- 合唱者将伴奏、主唱的干声(Audio1)和合唱者自己的干声(Audio2)混音后发给观众。
- 观众只听合唱者的声音。
合唱准备
在合唱准备阶段,您需要完成以下配置:
音频配置
-
在加入 NERoom 房间前调用
setChannelProfile
接口,设置房间场景为直播场景(LiveBroadcasting
)。 -
在加入 NERoom 房间前调用
setLocalAudioProfile
接口,设置音频profile
类型为HIGH_QUALITY_STEREO
,设置scenario
为MUSIC
。 -
设置
MixedAudioFrame
和RecordingAudioFrame
的混流参数。- 设置音频推流声道数量为 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)
主唱和合唱者上麦
-
用户调用
NESeatController.submitSeatRequest
接口申请上麦,成为连麦主播。上麦成功后,房间内所有成员收到
RoomListener#onMemberPropertiesChanged
的回调。如果无空闲麦位,则不允许申请上麦,需要您在业务中自行实现相关逻辑。
currentRoomContext?.seatController?.cancelSeatRequest(callback)
-
查询房间内成员列表,通过成员的
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()
}
})
- 添加麦位监听事件。
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()
}
})
点歌
-
麦位上的用户可以点歌,具体实现逻辑需要业务自行实现。
-
歌词展示与同步,具体实现逻辑实现需要业务自行实现。
合唱
时序图
实现流程
- 主唱发起合唱邀请,具体实现方式需要业务自行实现。
- 业务服务器可通过IM聊天室消息发送发送自定义消息,并在 NERoom 中监听这些自定义消息。
- 业务方也可通过 NERoom 的
NEMessageChannelService
服务提供的自定义消息通道发送自定义消息。
-
观众同意合唱,并通过
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; }
-
主唱和合唱者在开始合唱前,调用如下代码开启 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)