实现实时合唱
更新时间: 2025/09/22 13:44:21
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 实时合唱 | 串行合唱 |
|---|---|---|
| 主唱体验 | 能实时听到伴奏和合唱者的声音 | 只能听到伴奏,不能听到合唱者的声音 |
| 合唱者体验 | 能实时听到伴奏和主唱的声音 | 听到伴奏和主唱同步 |
| 观众体验 | 能实时听到伴奏、主唱的声音、合唱者的声音,且三者同步 | 听到伴奏、主唱的声音、合唱者的声音,且三者同步 |
| 硬件要求 | 对网络和机型有一定要求 | 抗弱网性能好,机型覆盖全 |
| 实现难度 | 接入成本低 | 接入成本中等 |
前提条件
- 已实现 加入和离开 RTC 房间。
- 调用
setChannelProfile接口,将房间场景为STANDARD_CHATROOM场景。
合唱准备
在合唱准备阶段,您需要完成以下配置:
设置 NTP 对齐
在实时合唱方案中,需要在开唱后实时同步伴奏进度,避免因伴奏误差而增加端到端延迟。但不同设备的本地时钟并不一致,存在一定误差,因此需要主唱和合唱者通过 NTP 对齐播放伴奏的时间,达到实时的效果。
-
进入房间前,主唱和合唱者调用
setStreamAlignmentProperty接口,将参数值设置为YES,设置 NTP 时间对齐。设置成功后,设备会多次与 RTC 服务器校准 NTP 值,该配置能让
getNtpTimeOffset的值更加精准。示例代码如下:
Java// 加入房间前设置,设置了NTP 精准对齐后,每隔10分钟会重新校验NTP值。 NERtcEx.getInstance().setStreamAlignmentProperty(true); -
开始合唱前,主唱和合唱者分别获取本地系统时间与 RTC 服务端时间的 NTP 时间差。
Javalong timeOffset = NERtcEx.getInstance().getNtpTimeOffset(); -
主唱或合唱者收到开始合唱信令,主唱本地播放伴奏,并发送播放伴奏信令给合唱者。
例如,主唱播放伴奏的时间设置为 3 秒之后,需要等合唱者同一时间一起播放伴奏。
-
NERtcCreateAudioEffectOption的startTimeStamp设置为 UTC时间 + 倒计时时间。Javalong startTimeStamp = System.currentTimeMillis() + 3 * 1000; NERtcCreateAudioEffectOption option = new NERtcCreateAudioEffectOption(); option.startTimestamp = startTimeStamp; -
主唱发送播放伴奏信令给合唱者。信令中包含当前服务器的 UTC 时间 + 倒计时时间。
Javaprivate 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 时间。
Java//伪代码 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; }
主唱和合唱者上麦
麦位相关的实现需要业务自行实现。
点歌
合唱
时序图
sequenceDiagram
participant 主唱
participant 副唱
participant 听众
rect rgb(240,248,255)
Note over 主唱,副唱: 进入房间
主唱->>主唱: 进入房间调用<br>setStreamAlignmentProperty<br>设置 NTP 精准对齐
副唱->>副唱: 进入房间调用<br>setStreamAlignmentProperty<br>设置 NTP 精准对齐
end
rect rgb(255,250,240)
Note over 主唱,听众: 开始唱歌
副唱->>副唱: 副唱不订阅主唱伴奏<br>仅本地播放伴奏<br>subscribeRemoteSubstreamAudio
主唱->>主唱: 调用 setChannelProfile 设置房间场景为 Karaoke
副唱->>副唱: 调用 setChannelProfile 设置房间场景为 Karaoke
主唱->>主唱: 设置辅流 enableLocalSubStreamAudio
主唱->>副唱: 调用 playEffect<br>以辅流方式发送伴奏
副唱->>听众: 转发伴奏
副唱->>副唱: getNtpTimeOffset 对齐播放开始时间<br>不发送本地播放伴奏<br>设置伴奏 sendVolume 为 0<br>sendEnabled 为 false
主唱->>主唱: updateAudioEffectTimestamp<br>收到伴奏进度回调
主唱->>副唱: sendSEIMsg 将自己的播放进度发出去
副唱->>听众: 转发 SEI 消息
副唱->>副唱: onRecvSEIMsg<br>收到 SEIMsg,同步歌词进度
听众->>听众: onRecvSEIMsg<br>收到 SEIMsg,同步歌词进度
end
rect rgb(240,248,255)
Note over 主唱,副唱: 结束唱歌
主唱->>主唱: 停止合唱:stopEffect<br>关闭辅流:enableLocalSubStreamAudio<br>订阅主播伴奏:subscribeRemoteSubStreamAudio
副唱->>副唱: 停止合唱:stopEffect<br>订阅主播伴奏:subscribeRemoteSubStreamAudio
主唱->>主唱: 设置房间场景为 STANDARD_CHATROOM
副唱->>副唱: 设置房间场景为 STANDARD_CHATROOM
end
实现流程
-
主唱发起合唱邀请,具体实现方式需要业务自行实现。
-
观众同意合唱,并申请上麦。上麦成功后,调用
enableLocalAudio接口,将参数设置为true,开启本地音频采集和发送。以下示例代码展示通过 IM 实现上麦,您可以自行实现相关逻辑。
Java//申请上麦 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);
-
合唱前,主唱和合唱者调用
setChannelProfile接口将房间场景设置为Karaoke场景,然后开始合唱。Java
NERtcEx.getInstance().setChannelProfile(NERtcConstants.RTCChannelProfile.Karaoke); -
合唱者在本地播放伴奏,等待合唱者完成下载歌曲文件和歌词。
-
设置合唱者不订阅主唱的伴奏。
JavaNERtcEx.getInstance().subscribeRemoteSubStreamAudio(anchorUserID, false); -
主唱以辅流方式发送伴奏给合唱者和观众。
Java//当前时间加上倒计时的时间,就是即将要播放的时间。 单位:毫秒 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); //pureEffectId 为用户自定义的纯伴奏的 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); //originEffectId 为用户自定义的带原声的effect id -
主唱和合唱者通过
updateAudioEffectTimestamp同步本地歌词,主唱调用sendSEIMsg接口,发送播放进度给观众。Java@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回调,同步歌词进度。Java@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; } } -
合唱者开始本地播放伴奏,但不发送伴奏。
Javalong 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 -
主唱结束合唱。
Java//主唱停止合唱 NERtcEx.getInstance().stopEffect(effectId) //主唱关闭辅流 NERtcEx.getInstance().enableLocalSubStreamAudio(false); //主唱恢复订阅合唱者音频 NERtcEx.getInstance().subscribeRemoteSubStreamAudio(chorusUserId, true); //恢复合唱者音频的订阅 -
合唱者结束合唱。
Java//合唱者结束合唱 NERtcEx.getInstance().stopEffect(effectId) //合唱者恢复订阅主唱伴奏 NERtcEx.getInstance().subscribeRemoteSubStreamAudio(anchorId, true); //恢复主唱音频的订阅 -
主唱和合唱者将房间场景设置为
STANDARD_CHATROOM。Java
NERtcEx.getInstance().setChannelProfile(NERtcConstants.RTCChannelProfile.STANDARD_CHATROOM);
进阶功能
主唱、合唱者静音和取消静音
建议通过以下方式静音和取消静音,以免损耗性能。该设置只影响麦克风采集音量,不影响发送的伴奏音量。
Java//mute
NERtcEx.getInstance().adjustRecordingSignalVolume(0);
//unmute
NERtcEx.getInstance().adjustRecordingSignalVolume(100);
切换原唱和伴奏
实现切换播放原唱的示例代码如下:
Java//将带原唱的音效音量调整到正常,并将纯伴奏的音效音量调整成 0
NERtcEx.getInstance().setEffectSendVolume(pureEffectId, 0);
NERtcEx.getInstance().setEffectPlaybackVolume(pureEffectId, 0);
NERtcEx.getInstance().setEffectSendVolume(originEffectId, audioMixVolume);
NERtcEx.getInstance().setEffectPlaybackVolume(originEffectId, audioMixVolume);
实现切换为播放伴奏的示例代码如下:
Java//将带原唱的音效音量调整成 0,并将纯伴奏的音效音量调整成正常
NERtcEx.getInstance().setEffectSendVolume(pureEffectId, audioMixVolume);
NERtcEx.getInstance().setEffectPlaybackVolume(pureEffectId, audioMixVolume);
NERtcEx.getInstance().setEffectSendVolume(originEffectId, 0);
NERtcEx.getInstance().setEffectPlaybackVolume(originEffectId, 0);





