实现NTP实时合唱
更新时间: 2022/11/11 18:01:38
NTP 实时合唱方案通过 NTP 对齐伴奏播放时间,使得双方在在弱网情况下也能精准同步,保证演唱者体验。实时合唱技术避免了合唱者对主唱伴奏的依赖,双方同时起步声音延迟更低。
注意事项
- 主唱和合唱者都需要播放伴奏。
- 主唱以辅流方式发送伴奏,合唱者不发送伴奏。
- 合唱者不订阅主唱的辅流音频。
- 主唱和合唱者在合唱开始时,需要打开低延时模式。
功能原理
NTP 实时合唱的原理图如下。
NTP 实时合唱的原理说明如下:
- 主唱本地播放伴奏,以辅流方式发送伴奏给 RTC 服务器。
- 合唱者本地播放伴奏,不订阅主唱的伴奏(辅流),也不发送伴奏给 RTC 服务器。
- 主唱和合唱者将自己干声发送给 RTC 服务器。RTC 服务器将伴奏、主唱干声(Audio1)、合唱者干声(Audio2)通过SDK 精准同步混流后,发给观众。
- 主唱能同时听到合唱者的干声(Audio2)。
NTP对齐的原理说明如下:
在实时合唱方案中,需要在开唱后实时同步伴奏进度,避免因伴奏误差而增加端到端延迟。但不同设备的本地时钟并不一致,存在一定误差,因此需要主唱和合唱者通过 NTP 对齐播放伴奏的时间,达到实时的效果,对齐伴奏的原理说明如下:
- 主唱和合唱者分别获取本地系统时间与 RTC 服务端时间的 NTP 时间差。
- 主唱推送本地播放伴奏的开始时间和倒计时时间,并根据主唱的 NTP 时间差,换算成 RTC 服务端的 UTC 时间。
- 合唱者根据 RTC 服务端的 UTC 时间以及合唱者的 NTP 时间差,计算得出本地播放伴奏的 UTC 时间。
功能介绍
NTP 实时合唱和串行合唱的方案对比如下表所示。
维度 | NTP 实时合唱 | 串行合唱 |
---|---|---|
主唱体验 | 能实时听到伴奏和合唱者的声音 | 只能听到伴奏,不能听到合唱者的声音 |
合唱者体验 | 能实时听到伴奏和主唱的声音 | 听到伴奏和主唱同步 |
观众体验 | 能实时听到伴奏、主唱的声音、合唱者的声音,且三者同步 | 听到伴奏、主唱的声音、合唱者的声音,且三者同步 |
硬件要求 | 对网络和机型有一定要求 | 抗弱网性能好,机型覆盖全 |
实现难度 | 接入成本低 | 接入成本中等 |
前提条件
合唱准备
在合唱准备阶段,您需要完成以下配置:
设置 NTP 对齐
在实时合唱方案中,需要在开唱后实时同步伴奏进度,避免因伴奏误差而增加端到端延迟。但不同设备的本地时钟并不一致,存在一定误差,因此需要主唱和合唱者通过 NTP 对齐播放伴奏的时间,达到实时的效果。
-
进入房间前,主唱和合唱者调用
setStreamAlignmentProperty
接口,将参数值设置为YES
,设置 NTP 时间对齐。设置成功后,设备会多次与 RTC 服务器校准 NTP 值,该配置能让
getNtpTimeOffset
的值更加精准。示例代码如下:
// 加入房间前设置,设置了NTP 精准对齐后,每隔10分钟会重新校验NTP值。 NERtcEx.getInstance().setStreamAlignmentProperty(true);
-
开始合唱前,主唱和合唱者分别获取本地系统时间与 RTC 服务端时间的 NTP 时间差。
long timeOffset = NERtcEx.getInstance().getNtpTimeOffset();
-
主唱或合唱者收到开始合唱信令, 主唱本地播放伴奏,并发送播放伴奏信令给合唱者。
例如,主唱播放伴奏的时间设置为 3 秒之后,需要等合唱者同一时间一起播放伴奏。
-
NERtcCreateAudioEffectOption
的startTimeStamp
设置为 UTC时间 + 倒计时时间。long startTimeStamp = System.currentTimeMillis() + 3 * 1000; NERtcCreateAudioEffectOption option = new NERtcCreateAudioEffectOption(); option.startTimestamp = startTimeStamp;
-
主唱发送播放伴奏信令给合唱者。信令中包含当前服务器的 UTC 时间 + 倒计时时间。
private void sendChrousSignal(){ long localTimeStamp = System.currentTimeMillis(); long ntpOffsetTime = NERtcEx.getInstance().getNtpTimeOffset(); long serverTimeStamp = localTimeStamp - ntpOffsetTime; long serverPlayTimeStamp = serverTimeStamp + 3 * 1000; //伪代码,发送播放伴奏信令,信令中包含serverPlayTimeStamp的值 NIMClient.getInstance().sendMessage(message); }
-
-
合唱者收到主唱发送的播放伴奏信令,根据播放伴奏的服务器 UTC 时间 + 自己的 NTP 时间差,计算得到自己播放伴奏的 UTC 时间。
//伪代码 public void onReceiveChorusSignMessage(NIMMessage customMessage) { long serverPlayTimeStamp = customMessage.getChorusAttchment().getServerPlayTimeStamp(); //服务器正式播放伴音的UTC时间 long ntpOffsetTime = NERtcEx.getInstance().getNtpTimeOffset(); long localPlayTimeStamp = serverPlayTimeStamp + ntpOffsetTime; //当前设备播放伴音的UTC时间 NERtcCreateAudioEffectOption option = new NERtcCreateAudioEffectOption(); option.startTimestamp = localPlayTimeStamp; }
主唱开启低延时模式
NERtcParameters neRtcParameters = new NERtcParameters();
NERtcParameters.Key privateJsonKey = NERtcParameters.Key.createSpecializedKey("engine.audio.ktv.chrous");
neRtcParameters.set(privateJsonKey, true);
NERtcEx.getInstance().setParameters(neRtcParameters);
音频配置
-
在加入房间前调用
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.MIDDLE_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().subscribeRemoteSubStreamAudio(anchorUserID, false);
-
主唱以辅流方式发送伴奏给合唱者和观众。
//当前时间加上倒计时的时间,就是即将要播放的时间。 单位:毫秒 long position = System.currentTimeMillis() + lastCountSec * 1000; 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); //纯伴奏的effect id. 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); //带原声的effect id
-
主唱和合唱者通过
updateAudioEffectTimestamp
同步本地歌词,主唱调用sendSEIMsg
接口,发送播放进度给观众。@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; //同步本地歌词进度 }
-
观众根据收到的
onRecvSEIMsg
回调,同步歌词进度。@Override public void onRecvSEIMsg(long l, String s) { if(观众) { 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 ; } dataSource.pickService.musicPosition = musicPosition; } }
-
合唱者开始本地播放伴奏,但不发送伴奏。
long ntpTimeOffset = NERtcEx.getInstance().getNtpTimeOffset(); long timeStamp = ktvInfo.ntp_time_stamp + offset; long localTimeStamp = timeStamp + ntpTimeOffset + ktvInfo.audio_offset; NERtcCreateAudioEffectOption pureOpt = new NERtcCreateAudioEffectOption(); pureOpt.path = 纯伴奏音乐路径; pureOpt.loopCount = 1; pureOpt.sendEnabled = false; pureOpt.sendVolume = 发送音量; pureOpt.playbackVolume = 播放音量; pureOpt.startTimestamp = localTimeStamp; NERtcEx.getInstance().playEffect(pureEffectId, pureOpt); //pureEffectId 自己定义的 effectId NERtcCreateAudioEffectOption originOpt = new NERtcCreateAudioEffectOption(); originOpt.path = 带原声的音乐路径; originOpt.loopCount = 1; originOpt.sendEnabled = false; originOpt.sendVolume = 0; //如果当前放纯伴奏音乐,将带原唱的伴奏发送音量设成0 originOpt.playbackVolume = 0; //如果当前放纯伴奏音乐,将带原唱的播放音量设成0 originOpt.startTimestamp = localTimeStamp; NERtcEx.getInstance().playEffect(originEffectId, originOpt); //originEffectId 自己定义的effectId
-
主唱结束合唱。
//主唱关闭低延时模式 NERtcParameters neRtcParameters = new NERtcParameters(); NERtcParameters.Key privateJsonKey = NERtcParameters.Key.createSpecializedKey("engine.audio.ktv.chrous"); neRtcParameters.set(privateJsonKey, false); NERtcEx.getInstance().setParameters(neRtcParameters); //主唱关闭辅流 NERtcEx.getInstance().enableLocalSubStreamAudio(false); //主唱恢复订阅合唱者音频 NERtcEx.getInstance().subscribeRemoteSubStreamAudio(chorusUserId, true); //恢复合唱者音频的订阅 //关闭AEC的伴奏模式 NERtcParameters mRtcParameters = new NERtcParameters(); NERtcParameters.Key audioMixKey = NERtcParameters.Key.createSpecializedKey("key_audio_external_audio_mix"); mRtcParameters.set(audioMixKey, false); NERtcEx.getInstance().setParameters(mRtcParameters); //先设置参数,后初始化
-
合唱者结束合唱。
//合唱者关闭低延时模式 NERtcParameters neRtcParameters = new NERtcParameters(); NERtcParameters.Key privateJsonKey = NERtcParameters.Key.createSpecializedKey("engine.audio.ktv.chrous"); neRtcParameters.set(privateJsonKey, false); NERtcEx.getInstance().setParameters(neRtcParameters); //合唱者恢复订阅主唱伴奏 NERtcEx.getInstance().subscribeRemoteSubStreamAudio(anchorId, true); //恢复主唱音频的订阅 //关闭AEC的伴奏模式 NERtcParameters mRtcParameters = new NERtcParameters(); NERtcParameters.Key audioMixKey = NERtcParameters.Key.createSpecializedKey("key_audio_external_audio_mix"); mRtcParameters.set(audioMixKey, false); NERtcEx.getInstance().setParameters(mRtcParameters); //先设置参数,后初始化
进阶功能
主唱、合唱者静音和取消静音
建议通过以下方式静音和取消静音,以免损耗性能。该设置只影响麦克风采集音量,不影响发送的伴奏音量。
//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,并将纯伴奏的音效音量调整成正常
NERtcEx.getInstance().setEffectSendVolume(pureEffectId, audioMixVolume);
NERtcEx.getInstance().setEffectPlaybackVolume(pureEffectId, audioMixVolume);
NERtcEx.getInstance().setEffectSendVolume(originEffectId, 0);
NERtcEx.getInstance().setEffectPlaybackVolume(originEffectId, 0);