实现实时合唱

更新时间: 2025/09/22 13:44:21

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

前提条件

合唱准备

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

设置 NTP 对齐

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

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

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

    示例代码如下:

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

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

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

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

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

主唱和合唱者上麦

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

点歌

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

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

合唱

时序图

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

实现流程

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

  2. 观众同意合唱,并申请上麦。上麦成功后,调用 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);
    
  1. 合唱前,主唱和合唱者调用 setChannelProfile 接口将房间场景设置为 Karaoke 场景,然后开始合唱。

    JavaNERtcEx.getInstance().setChannelProfile(NERtcConstants.RTCChannelProfile.Karaoke);
    
  2. 合唱者在本地播放伴奏,等待合唱者完成下载歌曲文件和歌词。

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

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

    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
    
  5. 主唱和合唱者通过 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; //同步本地歌词进度
    }
    
  6. 观众根据收到的 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;
        }
    }
    
  7. 合唱者开始本地播放伴奏,但不发送伴奏。

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

    Java//主唱停止合唱
    NERtcEx.getInstance().stopEffect(effectId)
    
    //主唱关闭辅流
    NERtcEx.getInstance().enableLocalSubStreamAudio(false);
        
    //主唱恢复订阅合唱者音频
    NERtcEx.getInstance().subscribeRemoteSubStreamAudio(chorusUserId, true); //恢复合唱者音频的订阅
    
  9. 合唱者结束合唱。

    Java//合唱者结束合唱
    NERtcEx.getInstance().stopEffect(effectId)
    
    //合唱者恢复订阅主唱伴奏
    NERtcEx.getInstance().subscribeRemoteSubStreamAudio(anchorId, true); //恢复主唱音频的订阅
    
  10. 主唱和合唱者将房间场景设置为 STANDARD_CHATROOM

    JavaNERtcEx.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);
此文档是否对你有帮助?
有帮助
去反馈
  • 注意事项
  • 功能原理
  • 功能介绍
  • 前提条件
  • 合唱准备
  • 设置 NTP 对齐
  • 主唱和合唱者上麦
  • 点歌
  • 合唱
  • 进阶功能
  • 主唱、合唱者静音和取消静音
  • 切换原唱和伴奏