实现串行合唱

更新时间: 2022/11/11 10:00:36

串行合唱场景中,主唱将伴奏和人声发送给合唱者,合唱者根据主唱的伴奏进行合唱,并混流发给观众。

前提条件

已实现加入和离开 RTC 房间。

功能原理

串行合唱的原理图如下。

串行合唱.png

串行合唱的原理说明如下:

  1. 主唱播放伴奏,并以主流方式发送伴奏。
  2. 主唱的 NERTC SDK 将主唱的干声(Audio1)和伴奏合流后发给合唱者,主唱的干声(Audio1)和伴奏只有合唱者才能听到,主唱不订阅合唱者的干声。
  3. 合唱者将伴奏、主唱的干声(Audio1)和合唱者自己的干声(Audio2)混音后发给观众。
  4. 观众只听合唱者的声音。

功能介绍

NTP 实时合唱和串行合唱的方案对比如下表所示。

维度 NTP 实时合唱 串行合唱
主唱体验 能实时听到伴奏和合唱者的声音 只能听到伴奏,不能听到合唱者的声音
合唱者体验 能实时听到伴奏和主唱的声音 听到伴奏和主唱同步
观众体验 能实时听到伴奏、主唱的声音、合唱者的声音,且三者同步 听到伴奏、主唱的声音、合唱者的声音,且三者同步
硬件要求 对网络和机型有一定要求 抗弱网性能好,机型覆盖全
实现难度 接入成本低 接入成本中等

合唱准备

在合唱准备阶段,您需要完成以下配置:

音频配置

  1. 在加入房间前调用 setChannelProfile 接口,设置房间场景为直播场景(LiveBroadcasting)。

  2. 在加入房间前调用 setAudioProfile 接口,设置音频 profile 类型为 HighQualityStereo,设置 scenarioMUSIC

  3. 设置 MixedAudioFrameRecordingAudioFrame 的混流参数。

    • 调用 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];

主唱和合唱者上麦

麦位相关的实现逻辑需要业务自行实现。

点歌

  1. 麦位上的用户可以点歌,具体实现逻辑需要业务自行实现。

  2. 下载歌曲文件和并展现歌词,具体实现请参见版权音乐歌词展示与同步

合唱

时序图

串行合唱.jpg

实现流程

  1. 主唱发起合唱邀请,具体实现方式需要业务自行实现。

  2. 观众同意合唱,并申请上麦。上麦成功后,调用 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];
    
    
  3. 主唱和合唱者在开始合唱前,调用如下代码开启 AEC 伴奏模式。

    开启 AEC 伴奏模式时,本端的人声保留比较好,有助于演唱者的唱歌体验。

    [NERtcEngine.sharedEngine setParameters:@{@"kNERtcKeyAudioProcessingExternalAudioMixEnable": @YES}];
    
    
  4. 唱歌开始前,设置主唱不订阅合唱者的声音,并且自己的声音只发给合唱者。

    [NERtcEngine.sharedEngine subscribeRemoteAudio:NO forUserID:userID];  //不订阅合唱者的音频
    [NERtcEngine.sharedEngine setAudioSubscribeOnlyBy:@[@(userID)]];  //自己的音频只给合唱者
    
  5. 主唱调用 playEffectWitdId接口,以主流方式发送伴奏给合唱者。

    收到歌曲开始消息后,开始 3 秒倒计时,3 秒后开始播放伴奏。

    SInt64 position = ([NSDate date].timeIntervalSince1970)* 1000 + 3 * 1000; //3 秒后开始播放伴奏
    NERtcCreateAudioEffectOption *optPure = [[NERtcCreateAudioEffectOption alloc] init];
    optPure.path = 纯伴奏音乐路径
    optPure.loopCount = 1;
    optPure.sendEnabled = YES;
    optPure.sendVolume = 发送音量; 
    optPure.playbackVolume = 播放音量;
    optPure.sendWithAudioType = kNERtcAudioStreamTypeMain; //伴奏以主流方式发送
    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.sendWithAudioType = kNERtcAudioStreamTypeMain; //伴奏以主流方式发送
    optOriginalSong.startTimeStamp = position;
    [NERtcEngine.sharedEngine playEffectWitdId:kEffectIdOriginalSong effectOption:optOriginalSong]; // kEffectIdOriginalSong  自己定义effect id 
    
  6. 主唱收到伴奏播放进度回调,并将自己的伴奏进度通过 SEI 发送出去。

    - (void)onAudioEffectTimestampUpdateWithId:(uint32_t)effectId timeStampMS:(uint64_t)timeStampMS {
        //纯伴奏和带原唱的伴奏,两个进度是一样的,歌词进度只需根据其中一个伴奏的进度进行同步
        if(effectId != kEffectIdPureAccompantment) 
            return;    
            
        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;//同步本地歌词进度
    }
    
  7. 合唱者混音。

    串行合唱混音的详细示例代码请参见串行合唱的环形Buffer和混音代码

    //只有串行合唱模式下的合唱者,才需要去混音
    - (void)onNERtcEnginePlaybackAudioFrameBeforeMixingWithUserID:(uint64_t)userID frame:(NERtcAudioFrame *)frame
    {
        //当前的演唱模式不是串行模式,则返回
        if (_dataSource.chorusMode != NTESChorusModeSerial)
            return;
            
        //自己不是合唱者,则返回
        if(!_dataSource.selfIsSuperChorus)
            return;
            
        //收到的userID不是主唱,则返回。 只需要保持主唱的audioFrame
        if(userID != _dataSource.superAnchorUserID)
            return;
        
        int32_t size = frame.format.channels * frame.format.bytesPerSample * frame.format.samplesPerChannel;
        //环形buffer保存audioFrame,避免不断地alloc,free,造成内存碎片,具体上下文结构见上面的示例代码
        NMCTPCircularBufferProduceBytes(_recvBuffer,frame.data,size);
        _audioChannels = frame.format.channels;
        _samplesPerChannel = frame.format.samplesPerChannel;
        //留一点buffer,保证AudioFrameDidRecord时,有数据,避免听众听到的声音不流畅。
        if (_recvBuffer->fillCount >= 5*size) {
            _bStartMix = true;
        }
    }
    
    //只有串行合唱模式下的合唱者,才需要去混音
    - (void)onNERtcEngineAudioFrameDidRecord:(NERtcAudioFrame *)frame {
        //当前的演唱模式不是串行模式,则返回
        if (_dataSource.chorusMode != NTESChorusModeSerial)
            return;
        
        //自己不是合唱者,则返回
        if(!_dataSource.selfIsSuperChorus)
            return;    
        
        //保证环形buffer里有些数据缓存之后,再开始混音
        if (!_bStartMix)
            return;
            
        //ChannelProfile之前已设为LiveBroadcasting。
        //MixedAudioFrame 和 RecordingAudioFrame  需要设为 channels 为 2, sampleRate 为 48000
        //如果以上2个条件没设,主唱和合唱者的audioFrame格式不一致,没法混音。
        if (_audioChannels != frame.format.channels || _samplesPerChannel != frame.format.samplesPerChannel)
            return;
    
        int32_t availableBytes = 0;
        int32_t size = frame.format.channels * frame.format.bytesPerSample * frame.format.samplesPerChannel;
        void *buffer = NMCTPCircularBufferTail(_recvBuffer, &availableBytes);
        if (availableBytes >= size)
        {
            [PlayerAudioMixer mixAudioFrameData:frame.data data2:buffer samplesPerChannel:frame.format.samplesPerChannel channels:frame.format.channels];
            NMCTPCircularBufferConsume(_recvBuffer, size);
        }
    }  
    
  8. 合唱者和观众同步歌词。

    1. 合唱者收到主唱的 SEI,同步自己的歌词进度,再通过 SEI 发送自己的歌词进度。
    2. 观众收到合唱者的 SEI,同步自己的歌词进度。
    - (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);
        }
        
        //合唱者收到主唱的 SEI 同步自己的歌词,并发送自己的歌词进度
        if (_dataSource.selfIsSuperChorus) {
            uint32_t musicPosition = (uint32_t)[JSONObject[kAudioMixingPos] integerValue];
            self.dataSource.pickService.musicPosition = musicPosition;
            NSDictionary *dict = @{kAudioMixingPos: @(musicPosition)};
            NSData *data = [NSJSONSerialization dataWithJSONObject:dict options:0 error:nil];
            [NERtcEngine.sharedEngine sendSEIMsg:data];
        }
        //观众或上麦者收到合唱者的 SEI, 同步自己的歌词进度
        else if(userID == _dataSource.firstSuperChorusUserID){
            uint32_t musicPosition = (uint32_t)[JSONObject[kAudioMixingPos] integerValue];
            self.dataSource.pickService.musicPosition = musicPosition;
        }
    }
    
  9. 主唱结束合唱。

//恢复订阅副唱音频
[NERtcEngine.sharedEngine subscribeRemoteAudio:YES forUserID:userID];

//黑名单置空, 恢复听众听到主唱声音
[NERtcEngine.sharedEngine setAudioSubscribeOnlyBy:@[]];

//合唱者关闭低延时模式
[[NERtcEngine sharedEngine] setParameters:@{@"engine.audio.ktv.chrous": @NO}];

//关闭AEC的伴奏模式
[NERtcEngine.sharedEngine setParameters:@{@"kNERtcKeyAudioProcessingExternalAudioMixEnable": @NO}];
  1. 合唱者结束合唱。
//合唱者关闭低延时模式
[[NERtcEngine sharedEngine] setParameters:@{@"engine.audio.ktv.chrous": @NO}];

//关闭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];
此文档是否对你有帮助?
有帮助
去反馈
  • 前提条件
  • 功能原理
  • 功能介绍
  • 合唱准备
  • 音频配置
  • 主唱和合唱者上麦
  • 点歌
  • 合唱
  • 进阶功能
  • 主唱、合唱者静音和取消静音
  • 切换原唱和伴奏