实现NTP实时合唱
更新时间: 2024/11/26 15:44:05
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 精准对齐 [[NERtcEngine sharedEngine] setStreamAlignmentProperty:YES];
-
开始合唱前,主唱和合唱者分别获取本地系统时间与 RTC 服务端时间的 NTP 时间差。
- (int64_t)getNtpTimeOffset;
-
主唱或合唱者收到开始合唱信令, 主唱本地播放伴奏,并发送播放伴奏信令给合唱者。
例如,主唱播放伴奏的时间设置为 3 秒之后,需要等合唱者同一时间一起播放伴奏。
-
NERtcCreateAudioEffectOption
的startTimeStamp
设置为 UTC时间 + 倒计时时间。SInt64 startTimeStamp = ([NSDate date].timeIntervalSince1970)* 1000 + 3*1000; NERtcCreateAudioEffectOption *opt = [[NERtcCreateAudioEffectOption alloc] init]; opt.startTimeStamp = startTimeStamp;
-
主唱发送播放伴奏信令给合唱者。信令中包含当前服务器的 UTC 时间 + 倒计时时间。
- (void)sendChorusSignal { SInt64 localTimeStamp = ([NSDate date].timeIntervalSince1970)* 1000; SInt64 ntpOffsetTime = engine.getNtpTimeOffset; SInt64 serverTimeStamp = localTimeStamp - ntpOffsetTime; //当前服务器的UTC时间 SInt64 serverPlayTimeStamp = serverTimeStamp+3*1000; //正式开始播放伴奏的服务器 UTC时间 //发送播放伴奏信令,信令中包含serverPlayTimeStamp的值 [[NIMSDK sharedSDK].chatManager sendMessage:message]; }
-
-
合唱者收到主唱发送的播放伴奏信令,根据播放伴奏的服务器 UTC 时间 + 自己的 NTP 时间差,计算得到自己播放伴奏的 UTC 时间。
- (void)didReceiveChorusSignaMessage:(NIMMessage *)customMessage { SInt64 serverPlayTimeStamp = customMessage.chorusAttchment.serverPlayTimeStamp;//服务器正式播放伴音的UTC时间 SInt64 ntpOffsetTime = engine.getNtpTimeOffset; SInt64 localPlayTimeStamp = serverPlayTimeStamp + ntpOffsetTime; //当前设备播放伴音的UTC时间 NERtcCreateAudioEffectOption *opt = [[NERtcCreateAudioEffectOption alloc] init]; opt.startTimeStamp = localPlayTimeStamp; }
主唱开启低延时模式
[[NERtcEngine sharedEngine] setParameters:@{@"engine.audio.ktv.chrous": @YES}];
音频配置
-
在加入房间前调用
setChannelProfile
接口,设置房间场景为直播场景(LiveBroadcasting
)。 -
在加入房间前调用
setAudioProfile
接口,设置音频profile
类型为HighQualityStereo
,设置scenario
为MUSIC
。 -
设置
MixedAudioFrame
和RecordingAudioFrame
的混流参数。- 调用
channels
接口,设置音频推流声道数量为 2 。 - 调用
sampleRate
接口,设置设备采样率为 48000。
- 调用
示例代码如下:
[[NERtcEngine sharedEngine] setChannelProfile:kNERtcChannelProfileLiveBroadcasting];
[[NERtcEngine sharedEngine] setAudioProfile:kNERtcAudioProfileHighQualityStereo scenario:kNERtcAudioScenarioMusic];
NERtcAudioFrameRequestFormat *format = [[NERtcAudioFrameRequestFormat alloc] init];
format.channels = 2;
format.sampleRate = 48000;
format.mode = kNERtcAudioFrameOpModeReadOnly;
[[NERtcEngine sharedEngine] setMixedAudioFrameParameters:format];
NERtcAudioFrameRequestFormat *aFormat = [[NERtcAudioFrameRequestFormat alloc] init];
aFormat.channels = 2;
aFormat.sampleRate = 48000;
aFormat.mode = kNERtcAudioFrameOpModeReadWrite;
[[NERtcEngine sharedEngine] setRecordingAudioFrameParameters:aFormat];
音效优化
主唱和合唱者上麦
麦位相关的实现逻辑需要业务自行实现。
点歌
合唱
时序图
实现流程
-
主唱发起合唱邀请,具体实现方式需要业务自行实现。
-
观众同意合唱,并申请上麦。上麦成功后,调用
enableLocalAudio
接口,将参数设置为true
,开启本地音频采集和发送。以下示例代码展示通过 IM 实现上麦,您可以自行实现相关逻辑。
NSString *strUserID = [NSString stringWithFormat:@"%@",@(self.context.myAccountInfo.uid)]; NSDictionary *dict = @{@"type":@(NTESVoiceChatAttachmentTypeJoinChorusRequest), @"data":@{ @"userId":strUserID, @"nickName":self.context.myAccountInfo.nickName } }; NIMMessage *message = [NTESChorusMessageHelper messageWithChorusDict:dict]; [[NIMSDK sharedSDK].chatManager sendMessage:message toSession:self.session error:nil];
-
主唱和合唱者在开始合唱前,调用如下代码开启 AEC 伴奏模式。
开启 AEC 伴奏模式时,本端的人声保留比较好,有助于演唱者的唱歌体验。
[NERtcEngine.sharedEngine setParameters:@{@"kNERtcKeyAudioProcessingExternalAudioMixEnable": @YES}];
-
合唱者在本地播放伴奏,等合唱者完成下载歌曲文件和歌词后,开始合唱。
-
设置合唱者不订阅主唱的伴奏。
[NERtcEngine.sharedEngine subscribeRemoteSubStreamAudio:NO forUserID:主唱UserID];
-
主唱以辅流方式发送伴奏给合唱者和观众。
SInt64 position = ([NSDate date].timeIntervalSince1970)* 1000 + kCountTimeLastSec *1000; NERtcCreateAudioEffectOption *optPure = [[NERtcCreateAudioEffectOption alloc] init]; optPure.path = 纯伴奏音乐路径 optPure.loopCount = 1; optPure.sendEnabled = YES; optPure.sendVolume = 发送音量; optPure.playbackVolume = 播放音量; optPure.startTimeStamp = position; [NERtcEngine.sharedEngine playEffectWitdId:kEffectIdPureAccompantment effectOption:optPure]; // kEffectIdPureAccompantment 自己定义effect id NERtcCreateAudioEffectOption *optOriginalSong = [[NERtcCreateAudioEffectOption alloc] init]; optOriginalSong.path = 原唱伴奏音乐路径 optOriginalSong.loopCount = 1; optOriginalSong.sendEnabled = YES; optOriginalSong.sendVolume = 0; //如果当前放纯伴奏音乐,将带原唱的伴奏发送音量设成0 optOriginalSong.playbackVolume = 0; //如果当前放纯伴奏音乐,将带原唱的伴奏发送音量设成0 optOriginalSong.startTimeStamp = position; if (self.context.chorusMode == NTESChorusModeRealtime) { optPure.sendWithAudioType = kNERtcAudioStreamTypeSub; //伴奏以辅流方式发送 optOriginalSong.sendWithAudioType = kNERtcAudioStreamTypeSub; //伴奏以辅流方式发送 [NERtcEngine.sharedEngine enableLocalSubStreamAudio:YES]; //开启音频辅流 } [NERtcEngine.sharedEngine playEffectWitdId:kEffectIdOriginalSong effectOption:optOriginalSong]; // kEffectIdOriginalSong 自己定义effect id
-
主唱和合唱者通过
onAudioEffectTimestampUpdateWithId
同步本地歌词进度。 主唱调用sendSEIMsg
接口,发送播放进度给观众。- (void)onAudioEffectTimestampUpdateWithId:(uint32_t)effectId timeStampMS:(uint64_t)timeStampMS { //纯伴奏和带原唱的伴奏,两个进度是一样的,歌词进度只需根据其中一个伴奏的进度进行同步 if(effectId != kEffectIdPureAccompantment) return; if (主唱) { NSMutableDictionary *dict = [NSMutableDictionary dictionary]; dict[kAudioMixingPos] = @(timeStampMS); NSData *data = [NSJSONSerialization dataWithJSONObject:dict options:0 error:nil]; [NERtcEngine.sharedEngine sendSEIMsg:data]; } //主唱和合唱者同步本地歌词进度 self.dataSource.pickService.musicPosition = timeStampMS; }
-
观众根据收到的
onNERtcEngineRecvSEIMsg
回调,同步歌词进度。- (void)onNERtcEngineRecvSEIMsg:(uint64_t)userID message:(NSData *)message { NSError *error; NSDictionary *JSONObject = [NSJSONSerialization JSONObjectWithData:message options:0 error:&error]; if (error) { return NELPLogError(@"Error decode SEI message: %@", error); } if (听众) { self.musicPosition = (uint32_t)[JSONObject[kAudioMixingPos] integerValue]; self.dataSource.pickService.musicPosition = self.musicPosition; } }
-
合唱者开始本地播放伴奏,但不发送伴奏。
SInt64 ntpTimeOffset = [NERtcEngine sharedEngine].getNtpTimeOffset; SInt64 timeStamp = chorusAttchment.chorusModel.ntp_time_stamp + context.rtcConfig.timeOffset; SInt64 localTimeStamp = timeStamp + ntpTimeOffset + chorusAttchment.chorusModel.audio_offset; NERtcEngine *engine = [NERtcEngine sharedEngine]; NERtcCreateAudioEffectOption *optPure = [[NERtcCreateAudioEffectOption alloc] init]; optPure.path = 纯伴奏音乐路径 optPure.loopCount = 1; optPure.sendEnabled = NO; optPure.sendVolume = 发送音量; optPure.playbackVolume = 播放音量; optPure.startTimeStamp = localTimeStamp; [NERtcEngine.sharedEngine playEffectWitdId:kEffectIdPureAccompantment effectOption:optPure]; // kEffectIdPureAccompantment 自己定义effect id NERtcCreateAudioEffectOption *optOriginalSong = [[NERtcCreateAudioEffectOption alloc] init]; optOriginalSong.path = 原唱伴奏音乐路径 optOriginalSong.loopCount = 1; optOriginalSong.sendEnabled = NO; optOriginalSong.sendVolume = 0; //如果当前放纯伴奏音乐,将带原唱的伴奏发送音量设成0 optOriginalSong.playbackVolume = 0; //如果当前放纯伴奏音乐,将带原唱的伴奏发送音量设成0 optOriginalSong.startTimeStamp = localTimeStamp; [NERtcEngine.sharedEngine playEffectWitdId:kEffectIdOriginalSong effectOption:optOriginalSong]; // kEffectIdOriginalSong 自己定义effect id;
-
主唱结束合唱。
//主唱关闭低延时模式 [[NERtcEngine sharedEngine] setParameters:@{@"engine.audio.ktv.chrous": @NO}]; //主唱关闭辅流 [NERtcEngine.sharedEngine enableLocalSubStreamAudio:NO]; //关闭AEC的伴奏模式 [NERtcEngine.sharedEngine setParameters:@{@"kNERtcKeyAudioProcessingExternalAudioMixEnable": @NO}];
-
合唱者结束合唱。
//合唱者关闭低延时模式 [[NERtcEngine sharedEngine] setParameters:@{@"engine.audio.ktv.chrous": @NO}]; //合唱者恢复订阅主唱伴奏 [NERtcEngine.sharedEngine subscribeRemoteSubStreamAudio:YES forUserID:主唱UserID]; //关闭AEC的伴奏模式 [NERtcEngine.sharedEngine setParameters:@{@"kNERtcKeyAudioProcessingExternalAudioMixEnable": @NO}];
进阶功能
主唱、合唱者静音和取消静音
建议通过以下方式静音和取消静音,以免损耗性能。该设置只影响麦克风采集音量,不影响发送的伴奏音量。
//mute
[[NERtcEngine sharedEngine] adjustRecordingSignalVolume:0];
//unmute
[[NERtcEngine sharedEngine] adjustRecordingSignalVolume:100];
切换原唱和伴奏
实现切换播放原唱的示例代码如下:
//将带原唱的音量调整到正常,并将纯伴奏的音量调整成 0
[NERtcEngine.sharedEngine setEffectSendVolumeWithId:kEffectIdPureAccompantment volume:0];
[NERtcEngine.sharedEngine setEffectPlaybackVolumeWithId:kEffectIdPureAccompantment volume:0];
[NERtcEngine.sharedEngine setEffectSendVolumeWithId:kEffectIdOriginalSong volume:audioMixingVolume];
[NERtcEngine.sharedEngine setEffectPlaybackVolumeWithId:kEffectIdOriginalSong volume:audioMixingVolume];
实现切换为播放伴奏的示例代码如下:
//将带原唱的音量调整成 0,并将纯伴奏的音量调整成正常
[NERtcEngine.sharedEngine setEffectSendVolumeWithId:kEffectIdPureAccompantment volume:audioMixingVolume];
[NERtcEngine.sharedEngine setEffectPlaybackVolumeWithId:kEffectIdPureAccompantment volume:audioMixingVolume];
[NERtcEngine.sharedEngine setEffectSendVolumeWithId:kEffectIdOriginalSong volume:0];
[NERtcEngine.sharedEngine setEffectPlaybackVolumeWithId:kEffectIdOriginalSong volume:0];