实现NTP实时合唱

更新时间: 2022/11/11 10:01:38

NTP 实时合唱方案通过 NTP 对齐伴奏播放时间,使得双方在在弱网情况下也能精准同步,保证演唱者体验。实时合唱技术避免了合唱者对主唱伴奏的依赖,双方同时起步声音延迟更低。

注意事项

  • 主唱和合唱者都需要播放伴奏。
  • 主唱以辅流方式发送伴奏,合唱者不发送伴奏。
  • 合唱者不订阅主唱的辅流音频。
  • 主唱和合唱者在合唱开始时,需要打开低延时模式。

功能原理

NTP 实时合唱的原理图如下。

NTP实时合唱.png

NTP 实时合唱的原理说明如下:

  1. 主唱本地播放伴奏,以辅流方式发送伴奏给 RTC 服务器。
  2. 合唱者本地播放伴奏,不订阅主唱的伴奏(辅流),也不发送伴奏给 RTC 服务器。
  3. 主唱和合唱者将自己干声发送给 RTC 服务器。RTC 服务器将伴奏、主唱干声(Audio1)、合唱者干声(Audio2)通过SDK 精准同步混流后,发给观众。
  4. 主唱能同时听到合唱者的干声(Audio2)。

NTP对齐的原理说明如下:

在实时合唱方案中,需要在开唱后实时同步伴奏进度,避免因伴奏误差而增加端到端延迟。但不同设备的本地时钟并不一致,存在一定误差,因此需要主唱和合唱者通过 NTP 对齐播放伴奏的时间,达到实时的效果,对齐伴奏的原理说明如下:

  1. 主唱和合唱者分别获取本地系统时间与 RTC 服务端时间的 NTP 时间差。
  2. 主唱推送本地播放伴奏的开始时间和倒计时时间,并根据主唱的 NTP 时间差,换算成 RTC 服务端的 UTC 时间。
  3. 合唱者根据 RTC 服务端的 UTC 时间以及合唱者的 NTP 时间差,计算得出本地播放伴奏的 UTC 时间。

功能介绍

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

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

前提条件

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

合唱准备

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

设置 NTP 对齐

在实时合唱方案中,需要在开唱后实时同步伴奏进度,避免因伴奏误差而增加端到端延迟。但不同设备的本地时钟并不一致,存在一定误差,因此需要主唱和合唱者通过 NTP 对齐播放伴奏的时间,达到实时的效果。

  1. 进入房间前,主唱和合唱者调用 setStreamAlignmentProperty 接口,将参数值设置为 YES,设置 NTP 时间对齐。

    设置成功后,设备会多次与 RTC 服务器校准 NTP 值,该配置能让 getNtpTimeOffset 的值更加精准。

    示例代码如下:

    // 加入房间前设置,设置了NTP 精准对齐后,每隔10分钟会重新校验NTP值。
    NERtcEx.getInstance().setStreamAlignmentProperty(true);
    
  2. 开始合唱前,主唱和合唱者分别获取本地系统时间与 RTC 服务端时间的 NTP 时间差。

    long timeOffset = NERtcEx.getInstance().getNtpTimeOffset();
    
  3. 主唱或合唱者收到开始合唱信令, 主唱本地播放伴奏,并发送播放伴奏信令给合唱者。

    例如,主唱播放伴奏的时间设置为 3 秒之后,需要等合唱者同一时间一起播放伴奏。

    1. NERtcCreateAudioEffectOptionstartTimeStamp 设置为 UTC时间 + 倒计时时间。

      long startTimeStamp = System.currentTimeMillis() + 3 * 1000;
      NERtcCreateAudioEffectOption option = new NERtcCreateAudioEffectOption();
      option.startTimestamp = startTimeStamp;
      
    2. 主唱发送播放伴奏信令给合唱者。信令中包含当前服务器的 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);
      }
      
  4. 合唱者收到主唱发送的播放伴奏信令,根据播放伴奏的服务器 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);

音频配置

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

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

  3. 设置 MixedAudioFrameRecordingAudioFrame 的混流参数。

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

音效优化

开启 AI 降噪

设置美声变声与混响

主唱和合唱者上麦

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

点歌

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

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

合唱

时序图

AndroidNTP实时合唱时序图.jpg

实现流程

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

  2. 观众同意合唱,并申请上麦。上麦成功后,调用 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);
    
  3. 主唱和合唱者在开始合唱前,调用如下代码开启 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); //先设置参数,后初始化。 
    
  4. 合唱者在本地播放伴奏,等合唱者完成下载歌曲文件和歌词后,开始合唱。

  5. 设置合唱者不订阅主唱的伴奏。

    NERtcEx.getInstance().subscribeRemoteSubStreamAudio(anchorUserID, false);
    
  6. 主唱以辅流方式发送伴奏给合唱者和观众。

    //当前时间加上倒计时的时间,就是即将要播放的时间。 单位:毫秒
    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
    
  7. 主唱和合唱者通过 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; //同步本地歌词进度
    }
    
  8. 观众根据收到的 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;
        }
    }
    
  9. 合唱者开始本地播放伴奏,但不发送伴奏。

    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
    
    
  10. 主唱结束合唱。

    //主唱关闭低延时模式
    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); //先设置参数,后初始化
    
  11. 合唱者结束合唱。

    //合唱者关闭低延时模式
    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);
此文档是否对你有帮助?
有帮助
去反馈
  • 注意事项
  • 功能原理
  • 功能介绍
  • 前提条件
  • 合唱准备
  • 设置 NTP 对齐
  • 主唱开启低延时模式
  • 音频配置
  • 音效优化
  • 主唱和合唱者上麦
  • 点歌
  • 合唱
  • 进阶功能
  • 主唱、合唱者静音和取消静音
  • 切换原唱和伴奏