实现串行合唱
更新时间: 2023/05/12 16:18:23
串行合唱场景中,主唱将伴奏和人声发送给合唱者,合唱者根据主唱的伴奏进行合唱,并混流发给观众。
功能原理
串行合唱的原理图如下。
串行合唱的原理说明如下:
- 主唱播放伴奏,并以主流方式发送伴奏。
- 主唱的 NERTC SDK 将主唱的干声(Audio1)和伴奏合流后发给合唱者,主唱的干声(Audio1)和伴奏只有合唱者才能听到,主唱不订阅合唱者的干声。
- 合唱者将伴奏、主唱的干声(Audio1)和合唱者自己的干声(Audio2)混音后发给观众。
- 观众只听合唱者的声音。
功能介绍
NTP 实时合唱和串行合唱的方案对比如下表所示。
维度 | NTP 实时合唱 | 串行合唱 |
---|---|---|
主唱体验 | 能实时听到伴奏和合唱者的声音 | 只能听到伴奏,不能听到合唱者的声音 |
合唱者体验 | 能实时听到伴奏和主唱的声音 | 听到伴奏和主唱同步 |
观众体验 | 能实时听到伴奏、主唱的声音、合唱者的声音,且三者同步 | 听到伴奏、主唱的声音、合唱者的声音,且三者同步 |
硬件要求 | 对网络和机型有一定要求 | 抗弱网性能好,机型覆盖全 |
实现难度 | 接入成本低 | 接入成本中等 |
前提条件
合唱准备
在合唱准备阶段,您需要完成以下配置:
音频配置
-
在加入房间前调用
setChannelProfile
接口,设置房间场景为直播场景(LiveBroadcasting
)。 -
在加入房间前调用
setAudioProfile
接口,设置音频profile
类型为HighQualityStereo
,设置scenario
为MUSIC
。 -
设置
MixedAudioFrame
和RecordingAudioFrame
的混流参数。- 调用
channels
接口,设置音频推流声道数量为 2 。 - 调用
sampleRate
接口,设置设备采样率为 48000。
- 调用
示例代码如下:
NERtcEx.getInstance().setChannelProfile(NERtcConstants.RTCChannelProfile.LIVE_BROADCASTING);
NERtcEx.getInstance().setAudioProfile(NERtcConstants.AudioProfile.HIGH_QUALITY_STEREO, NERtcConstants.AudioScenario.MUSIC);
NERtcAudioFrameRequestFormat mixFormat = new NERtcAudioFrameRequestFormat();
mixFormat.setChannels(2);
mixFormat.setSampleRate(48000);
mixFormat.setOpMode(NERtcAudioFrameOpMode.kNERtcAudioFrameOpModeReadOnly);
NERtcEx.getInstance().setMixedAudioFrameParameters(mixFormat);
NERtcAudioFrameRequestFormat recordFormat = new NERtcAudioFrameRequestFormat();
recordFormat.setChannels(2);
recordFormat.setSampleRate(48000);
recordFormat.setOpMode(NERtcAudioFrameOpMode.kNERtcAudioFrameOpModeReadWrite);
NERtcEx.getInstance().setRecordingAudioFrameParameters(recordFormat);
音效优化
主唱和合唱者上麦
主唱和合唱者上麦
麦位相关的实现逻辑需要业务自行实现。
点歌
合唱
时序图
实现流程
-
主唱发起合唱邀请,具体实现方式需要业务自行实现。
-
观众同意合唱,并申请上麦。上麦成功后,调用
enableLocalAudio
接口,将参数设置为true
,开启本地音频采集和发送。以下示例代码展示通过 IM 实现上麦,您可以自行实现相关逻辑。
//申请上麦 sendNotification(SeatCommands.applySeat(voiceRoomInfo, user, seat), new RequestCallback<Void>() { @Override public void onSuccess(Void param) { if (mySeat == null) { return; } if (callback != null) { callback.onSuccess(param); } } @Override public void onFailed(int code) { if (callback != null) { callback.onFailed(code); } } @Override public void onException(Throwable exception) { if (callback != null) { callback.onException(exception); } } }); //开启本地音频采集和发送 NERtcEx.getInstance().enableLocalAudio(true);
-
主唱和合唱者在开始合唱前,调用如下代码开启 AEC 伴奏模式。
开启 AEC 伴奏模式时,本端的人声保留比较好,有助于演唱者的唱歌体验。
NERtcParameters mRtcParameters = new NERtcParameters(); NERtcParameters.Key audioMixKey = NERtcParameters.Key.createSpecializedKey("key_audio_external_audio_mix"); mRtcParameters.set(audioMixKey, true); NERtcEx.getInstance().setParameters(mRtcParameters);
-
唱歌开始前,设置主唱不订阅合唱者的声音,并且自己的声音只发给合唱者。
NERtcEx.getInstance().subscribeRemoteAudioStream(chrousUid, false); //不订阅合唱者的音频 long[] uids = new long[] {chrousUid}; NERtcEx.getInstance().setAudioSubscribeOnlyBy(uids); //自己的音频只给合唱者
-
主唱调用
playEffect
接口,以主流方式发送伴奏给合唱者。收到歌曲开始消息后,开始 3 秒倒计时,3 秒后开始播放伴奏。
long position = System.currentTimeMillis() + 3 * 1000; // 3秒后开始播放伴奏 NERtcCreateAudioEffectOption pureOpt = new NERtcCreateAudioEffectOption(); pureOpt.path = 纯伴奏音乐路径; pureOpt.loopCount = 1; pureOpt.sendEnabled = true; pureOpt.sendVolume = 发送音量; pureOpt.playbackVolume = 播放音量; pureOpt.sendWithAudioType = NERtcAudioStreamType.kNERtcAudioStreamTypeSub; //音效以辅流的方式发送 pureOpt.startTimestamp = position; NERtcEx.getInstance().playEffect(pureEffectId, pureOpt); //自己设置的effectid. NERtcCreateAudioEffectOption originOpt = new NERtcCreateAudioEffectOption(); originOpt.path = 原声伴奏音乐路径; originOpt.loopCount = 1; originOpt.sendEnabled = true; originOpt.sendVolume = 0; //如果当前放纯音效音乐,将带原唱的伴奏发送音量设置为0 originOpt.playbackVolume = 0; //如果当前放纯音效音乐,将带原唱的播放发送音量设置为0 originOpt.sendWithAudioType = NERtcAudioStreamType.kNERtcAudioStreamTypeSub; //音效以辅流的方式发送 originOpt.startTimestamp = position; NERtcEx.getInstance().playEffect(originEffectId, originOpt); //自己设置的effectid
-
主唱收到伴奏播放进度回调,并将自己的伴奏进度通过 SEI 发送出去。
@Override public void updateAudioEffectTimestamp(long effectId, long timestampMs) { //纯伴奏和带原唱的音效,两个进度是一样的,歌词进度只需要根据其中的一个音效进行同步 if(effectId != optOriginalEffectId) { return ; } if(主唱) { JSONObject jsonObject = new JSONObject(); try { jsonObject.put("audio_effect_pos", timestampMs); } catch (JSONException e) { e.printStackTrace(); } NERtcEx.getInstance().sendSEIMsg(jsonObject.toString()); } dataSource.pickService.musicPostion = timestampMs; //同步本地歌词进度 }
-
合唱者混音。
串行合唱混音的详细示例代码请参见串行合唱的混音代码(Android)。
//只有串行合唱模式下的合唱者,才需要去混音 @Override public void onPlaybackAudioFrameBeforeMixingWithUserID(long uid, NERtcAudioFrame neRtcAudioFrame) { //当前的演唱模式不是串行模式,则返回 if(dataSource.chorusMode != NERTC_CHORUS_SERIAL_MODE) { return ; } //自己不是合唱者,则返回 if(!isChrous) { return ; } //收到的uid不是主唱,则返回。只需要保持主唱的audioFrame if(uid != dataSource.anchorUserId) { return ; } try { NERtcAudioFormat format = neRtcAudioFrame.getFormat(); int length = format.getBytesPerSample() * format.getSamplesPerChannel() * format.getChannels(); byte[] buf = new byte[length]; neRtcAudioFrame.getData().get(buf); //===== 锁保护 ===== if(cacheFrames.size >= 10) { cacheFrames.poll(); } cacheFrames.offer(buf); //===== 锁保护 ===== } catch (Exception e) { e.printStackTrace(); } } //只有串行合唱模式下的合唱者,才需要去混音 @Override public void onRecordFrame(NERtcAudioFrame neRtcAudioFrame) { //当前的演唱模式不是串行模式,则返回 if(dataSource.chorusMode != NERTC_CHORUS_SERIAL_MODE) { return ; } //自己不是合唱者,则返回 if(!isChrous) { return ; } try { // ===== 锁保护 ====== if(cacheFrames.size() > 0) { byte[] buf = cacheFrames.poll(); if(buf == null) return; NERtcAudioFormat format = neRtcAudioFrame.getFormat(); int length = format.getBytesPerSample() * format.getSamplesPerChannel() * format.getChannels(); byte[] destBuffer = new byte[length]; ByteBuffer recordByteBuffer = neRtcAudioFrame.getData(); recordByteBuffer.position(0); recordByteBuffer.get(destBuffer,0,length); // mixer.mixAudioFrameData 的上下文请参见串行合唱的混音代码 byte[] buffer = mixer.mixAudioFrameData(destBuffer, buf, format.getSamplesPerChannel(), format.getChannels()); if(buffer == null) { ALog.i(LOG_TAG, "mixAudioFrameData buffer is null"); return ; } recordByteBuffer.position(0); recordByteBuffer.put(buffer); } // ===== 锁保护 ====== } catch (Exception e){ e.printStackTrace(); } }
-
合唱者和观众同步歌词。
- 合唱者收到主唱的 SEI,同步自己的歌词进度,再通过 SEI 发送自己的歌词进度。
- 观众收到合唱者的 SEI,同步自己的歌词进度。
@Override public void onRecvSEIMsg(long l, String s) { long musicPosition = -1; try { JSONObject data = new JSONObject(s); musicPosition = Integer.parseInt(data.getString("audio_effect_pos")); } catch (Exception e) { e.printStackTrace(); } if(musicPosition == -1) { Log.i(TAG, "Error decode SEI message"); return ; } //合唱者收到主唱的SEI同步自己的歌词,并发送自己的歌词进度 if(isChrous) { JSONObject jsonObject = new JSONObject(); try { jsonObject.put("audio_effect_pos", musicPosition); } catch (JSONException e) { e.printStackTrace(); } NERtcEx.getInstance().sendSEIMsg(jsonObject.toString()); dataSource.pickService.musicPosition = musicPosition; } // 上麦者或者观众收到合唱的SEI,同步自己的歌词进度 else if(isSeat || isAudience) { dataSource.pickService.musicPosition = musicPosition; } }
-
主唱结束合唱。
//恢复订阅副唱音频
NERtcEx.getInstance().subscribeRemoteAudioStream(chrousUid, true);
//黑名单置空,恢复听众听到主唱声音
NERtcEx.getInstance().setAudioSubscribeOnlyBy(null);
//合唱者关闭低延迟模式
NERtcParameters neRtcParameters = new NERtcParameters();
NERtcParameters.Key privateJsonKey = NERtcParameters.Key.createSpecializedKey("engine.audio.ktv.chrous");
neRtcParameters.set(privateJsonKey, false);
//关闭AEC的伴奏模式
NERtcParameters.Key audioMixKey = NERtcParameters.Key.createSpecializedKey("key_audio_external_audio_mix");
neRtcParameters.set(audioMixKey, false);
NERtcEx.getInstance().setParameters(neRtcParameters);
- 合唱者结束合唱。
//合唱者关闭低延时模式
NERtcParameters neRtcParameters = new NERtcParameters();
NERtcParameters.Key privateJsonKey = NERtcParameters.Key.createSpecializedKey("engine.audio.ktv.chrous");
neRtcParameters.set(privateJsonKey, false);
//关闭AEC的伴奏模式
NERtcParameters.Key audioMixKey = NERtcParameters.Key.createSpecializedKey("key_audio_external_audio_mix");
neRtcParameters.set(audioMixKey, false);
NERtcEx.getInstance().setParameters(neRtcParameters);
进阶功能
主唱、合唱者静音和取消静音
建议通过以下方式静音和取消静音,以免损耗性能。该设置只影响麦克风采集音量,不影响发送的伴奏音量。
//mute
NERtcEx.getInstance().adjustRecordingSignalVolume(0);
//unmute
NERtcEx.getInstance().adjustRecordingSignalVolume(100);
切换原唱和伴奏
切换为播放原唱的示例代码如下:
//将带原唱的音效音量调整到正常,并将纯伴奏的音量调整成 0
NERtcEx.getInstance().setEffectSendVolume(pureEffectId, 0);
NERtcEx.getInstance().setEffectPlaybackVolume(pureEffectId, 0);
NERtcEx.getInstance().setEffectSendVolume(originEffectId, audioMixVolume);
NERtcEx.getInstance().setEffectPlaybackVolume(originEffectId, audioMixVolume);
切换为播放伴奏的示例代码如下:
//将带原唱的音效音量调整成 0,并将纯伴奏的音量调整成正常
[NERtcEngine.sharedEngine setEffectSendVolumeWithId:kEffectIdPureAccompantment volume:audioMixingVolume];
[NERtcEngine.sharedEngine setEffectPlaybackVolumeWithId:kEffectIdPureAccompantment volume:audioMixingVolume];
[NERtcEngine.sharedEngine setEffectSendVolumeWithId:kEffectIdOriginalSong volume:0];
[NERtcEngine.sharedEngine setEffectPlaybackVolumeWithId volume:0];