Fix issue #4570 by supporting optional `msid` attribute in WebRTC SDP negotiation, enabling compatibility with libdatachannel and other clients that don't include msid information. SRS failed to negotiate WebRTC connections from libdatachannel clients because: - libdatachannel SDP lacks `a=ssrc:XX msid:stream_id track_id` attributes - SRS required msid information to create track descriptions - According to RFC 8830, the msid attribute and its appdata (track_id) are **optional** If diligently look at the SDP generated by libdatachannel: ``` a=ssrc:42 cname:video-send a=ssrc:43 cname:audio-send ``` It's deliberately missing the `a=ssrc:XX msid:stream_id track_id` line, comparing that with this one: ``` a=ssrc:42 cname:video-send a=ssrc:42 msid:stream_id video_track_id a=ssrc:43 cname:audio-send a=ssrc:43 msid:stream_id audio_track_id ``` In such a situation, to keep compatible with libdatachannel, if no msid line in sdp, SRS comprehensively and consistently uses: * app/stream as stream_id, such as live/livestream * type=video|audio, cname, and ssrc as track_id, such as track-video-video-send-43
This commit is contained in:
parent
3f2539d8fb
commit
6e93dd73b5
5
.vscode/README.md
vendored
5
.vscode/README.md
vendored
|
|
@ -40,6 +40,11 @@ cmake --build $HOME/git/srs/trunk/cmake/build
|
|||
|
||||
## macOS: SRS UTest
|
||||
|
||||
The most straightforward way is to select a test name like `WorkflowRtcManuallyVerifyForPublisher`,
|
||||
then select `Debug gtest (macOS CodeLLDB)` and run the debug.
|
||||
|
||||
Or you can use the following way to run specified test from the test panel.
|
||||
|
||||
Install the following extensions:
|
||||
|
||||
- C++ TestMate
|
||||
|
|
|
|||
65
.vscode/launch.json
vendored
65
.vscode/launch.json
vendored
|
|
@ -2,7 +2,7 @@
|
|||
"version": "0.2.0",
|
||||
"configurations": [
|
||||
{
|
||||
"name": "Launch SRS with conf/console.conf",
|
||||
"name": "Debug SRS with conf/console.conf",
|
||||
"type": "cppdbg",
|
||||
"request": "launch",
|
||||
"program": "${workspaceFolder}/trunk/cmake/build/srs",
|
||||
|
|
@ -30,7 +30,7 @@
|
|||
}
|
||||
},
|
||||
{
|
||||
"name": "Launch SRS with conf/rtc.conf",
|
||||
"name": "Debug SRS with conf/rtc.conf",
|
||||
"type": "cppdbg",
|
||||
"request": "launch",
|
||||
"program": "${workspaceFolder}/trunk/cmake/build/srs",
|
||||
|
|
@ -58,40 +58,41 @@
|
|||
}
|
||||
},
|
||||
{
|
||||
"name": "Launch SRS with console.conf",
|
||||
"type": "cppdbg",
|
||||
"request": "launch",
|
||||
"program": "${workspaceFolder}/trunk/cmake/build/srs",
|
||||
"args": ["-c", "console.conf"],
|
||||
"stopAtEntry": false,
|
||||
"cwd": "${workspaceFolder}/trunk",
|
||||
"environment": [],
|
||||
"externalConsole": false,
|
||||
"linux": {
|
||||
"MIMode": "gdb"
|
||||
},
|
||||
"osx": {
|
||||
"MIMode": "lldb"
|
||||
},
|
||||
"setupCommands": [
|
||||
{
|
||||
"description": "Enable pretty-printing for gdb",
|
||||
"text": "-enable-pretty-printing",
|
||||
"ignoreFailures": true
|
||||
}
|
||||
],
|
||||
"preLaunchTask": "build",
|
||||
"logging": {
|
||||
"engineLogging": true
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "Launch srs-proxy",
|
||||
"name": "Debug srs-proxy",
|
||||
"type": "go",
|
||||
"request": "launch",
|
||||
"mode": "auto",
|
||||
"cwd": "${workspaceFolder}/proxy",
|
||||
"program": "${workspaceFolder}/proxy"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "Debug SRS (macOS, CodeLLDB) console.conf",
|
||||
"type": "lldb",
|
||||
"request": "launch",
|
||||
"program": "${workspaceFolder}/trunk/cmake/build/srs",
|
||||
"args": ["-c", "console.conf"],
|
||||
"cwd": "${workspaceFolder}/trunk",
|
||||
"stopOnEntry": false,
|
||||
"terminal": "integrated",
|
||||
"initCommands": [
|
||||
"command script import lldb.formatters.cpp.libcxx"
|
||||
],
|
||||
"preLaunchTask": "build",
|
||||
"env": {},
|
||||
"sourceLanguages": ["cpp"]
|
||||
},
|
||||
{
|
||||
"name": "Debug gtest (macOS CodeLLDB)",
|
||||
"type": "lldb",
|
||||
"request": "launch",
|
||||
"program": "${workspaceFolder}/trunk/cmake/build/utest",
|
||||
"args": ["--gtest_filter=*${selectedText}*"],
|
||||
"cwd": "${workspaceFolder}/trunk",
|
||||
"terminal": "integrated",
|
||||
"initCommands": [
|
||||
"command script import lldb.formatters.cpp.libcxx"
|
||||
],
|
||||
"sourceLanguages": ["cpp"]
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
@ -7,6 +7,7 @@ The changelog for SRS.
|
|||
<a name="v7-changes"></a>
|
||||
|
||||
## SRS 7.0 Changelog
|
||||
* v7.0, 2025-11-11, AI: WebRTC: Support optional msid attribute per RFC 8830. v7.0.126 (#4570)
|
||||
* v7.0, 2025-11-11, AI: SRT: Stop TS parsing after codec detection. v7.0.125 (#4569)
|
||||
* v7.0, 2025-11-09, AI: WebRTC: Support G.711 (PCMU/PCMA) audio codec for WebRTC. v7.0.124 (#4075)
|
||||
* v7.0, 2025-11-08, AI: WebRTC: Support VP9 codec for WebRTC-to-WebRTC streaming. v7.0.123 (#4548)
|
||||
|
|
|
|||
|
|
@ -3601,12 +3601,25 @@ srs_error_t SrsRtcPublisherNegotiator::negotiate_publish_capability(SrsRtcUserCo
|
|||
for (int j = 0; j < (int)remote_media_desc.ssrc_infos_.size(); ++j) {
|
||||
const SrsSSRCInfo &ssrc_info = remote_media_desc.ssrc_infos_.at(j);
|
||||
|
||||
// Generate msid because it's optional in sdp.
|
||||
string msid_tracker = ssrc_info.msid_tracker_;
|
||||
if (msid_tracker.empty()) {
|
||||
msid_tracker = srs_fmt_sprintf("track-%s-%s-%d",
|
||||
track_desc->type_.c_str(), ssrc_info.cname_.c_str(), ssrc_info.ssrc_);
|
||||
}
|
||||
|
||||
// Generate msid because it's optional in sdp.
|
||||
string msid = ssrc_info.msid_;
|
||||
if (msid.empty()) {
|
||||
msid = req->app_ + "/" + req->stream_;
|
||||
}
|
||||
|
||||
// ssrc have same track id, will be description in the same track description.
|
||||
if (track_id != ssrc_info.msid_tracker_) {
|
||||
if (track_id != msid_tracker) {
|
||||
SrsRtcTrackDescription *track_desc_copy = track_desc->copy();
|
||||
track_desc_copy->ssrc_ = ssrc_info.ssrc_;
|
||||
track_desc_copy->id_ = ssrc_info.msid_tracker_;
|
||||
track_desc_copy->msid_ = ssrc_info.msid_;
|
||||
track_desc_copy->id_ = msid_tracker;
|
||||
track_desc_copy->msid_ = msid;
|
||||
|
||||
if (remote_media_desc.is_audio() && !stream_desc->audio_track_desc_) {
|
||||
stream_desc->audio_track_desc_ = track_desc_copy;
|
||||
|
|
@ -3616,7 +3629,7 @@ srs_error_t SrsRtcPublisherNegotiator::negotiate_publish_capability(SrsRtcUserCo
|
|||
srs_freep(track_desc_copy);
|
||||
}
|
||||
}
|
||||
track_id = ssrc_info.msid_tracker_;
|
||||
track_id = msid_tracker;
|
||||
}
|
||||
|
||||
// set track fec_ssrc and rtx_ssrc
|
||||
|
|
|
|||
|
|
@ -9,6 +9,6 @@
|
|||
|
||||
#define VERSION_MAJOR 7
|
||||
#define VERSION_MINOR 0
|
||||
#define VERSION_REVISION 125
|
||||
#define VERSION_REVISION 126
|
||||
|
||||
#endif
|
||||
|
|
@ -375,9 +375,10 @@ vector<SrsMediaPayloadType> SrsMediaDesc::find_media_with_encoding_name(const st
|
|||
transform(encoding_name.begin(), encoding_name.end(), upper_name.begin(), ::toupper);
|
||||
|
||||
for (size_t i = 0; i < payload_types_.size(); ++i) {
|
||||
if (payload_types_[i].encoding_name_ == std::string(lower_name.c_str()) ||
|
||||
payload_types_[i].encoding_name_ == std::string(upper_name.c_str())) {
|
||||
payloads.push_back(payload_types_[i]);
|
||||
SrsMediaPayloadType payload = payload_types_[i];
|
||||
if (payload.encoding_name_ == std::string(lower_name.c_str()) ||
|
||||
payload.encoding_name_ == std::string(upper_name.c_str())) {
|
||||
payloads.push_back(payload);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -2039,6 +2039,116 @@ VOID TEST(SrsRtcPublisherNegotiatorTest, TypicalUseScenario)
|
|||
EXPECT_EQ("video", video_sdp.media_descs_[0].type_);
|
||||
}
|
||||
|
||||
VOID TEST(SrsRtcPublisherNegotiatorTest, LibdatachannelUseScenario)
|
||||
{
|
||||
srs_error_t err;
|
||||
|
||||
// Create SrsRtcPublisherNegotiator
|
||||
SrsUniquePtr<SrsRtcPublisherNegotiator> negotiator(new SrsRtcPublisherNegotiator());
|
||||
|
||||
// Create mock request for initialization
|
||||
SrsUniquePtr<MockRtcConnectionRequest> mock_request(new MockRtcConnectionRequest("test.vhost", "live", "stream1"));
|
||||
|
||||
// Create mock RTC user config with remote SDP
|
||||
SrsUniquePtr<SrsRtcUserConfig> ruc(new SrsRtcUserConfig());
|
||||
ruc->req_ = mock_request->copy();
|
||||
ruc->publish_ = true;
|
||||
ruc->dtls_ = true;
|
||||
ruc->srtp_ = true;
|
||||
ruc->audio_before_video_ = true;
|
||||
|
||||
// SDP from issue 4570 - libdatachannel format with video first, then audio
|
||||
ruc->remote_sdp_str_ =
|
||||
"v=0\r\n"
|
||||
"o=- rtc 4158491451 0 IN IP4 127.0.0.1\r\n"
|
||||
"s=-\r\n"
|
||||
"t=0 0\r\n"
|
||||
"a=group:BUNDLE video audio\r\n"
|
||||
"a=group:LS video audio\r\n"
|
||||
"a=msid-semantic:WMS *\r\n"
|
||||
"a=ice-options:ice2,trickle\r\n"
|
||||
"a=fingerprint:sha-256 28:37:F7:18:77:FC:46:33:6F:B2:0F:12:83:C2:BF:5C:61:5E:96:EB:4B:B9:97:81:92:7C:82:10:97:B8:8E:60\r\n"
|
||||
"m=video 56144 UDP/TLS/RTP/SAVPF 96 97\r\n"
|
||||
"c=IN IP4 172.24.64.1\r\n"
|
||||
"a=mid:video\r\n"
|
||||
"a=sendonly\r\n"
|
||||
"a=ssrc:42 cname:video-send\r\n"
|
||||
"a=rtcp-mux\r\n"
|
||||
"a=rtpmap:96 H264/90000\r\n"
|
||||
"a=rtcp-fb:96 nack\r\n"
|
||||
"a=rtcp-fb:96 nack pli\r\n"
|
||||
"a=rtcp-fb:96 goog-remb\r\n"
|
||||
"a=fmtp:96 profile-level-id=42e01f;packetization-mode=1;level-asymmetry-allowed=1\r\n"
|
||||
"a=rtpmap:97 RTX/90000\r\n"
|
||||
"a=fmtp:97 apt=96\r\n"
|
||||
"a=setup:actpass\r\n"
|
||||
"a=ice-ufrag:fEw/\r\n"
|
||||
"a=ice-pwd:jBua8YGWQKc/Vn6Y9EZ9+0\r\n"
|
||||
"a=candidate:1 1 UDP 2122317823 172.24.64.1 56144 typ host\r\n"
|
||||
"a=candidate:2 1 UDP 2122315767 10.0.0.94 56144 typ host\r\n"
|
||||
"a=candidate:3 1 UDP 1686189695 111.43.134.137 56144 typ srflx raddr 0.0.0.0 rport 0\r\n"
|
||||
"a=end-of-candidates\r\n"
|
||||
"m=audio 56144 UDP/TLS/RTP/SAVPF 111\r\n"
|
||||
"c=IN IP4 172.24.64.1\r\n"
|
||||
"a=mid:audio\r\n"
|
||||
"a=sendonly\r\n"
|
||||
"a=ssrc:43 cname:audio-send\r\n"
|
||||
"a=rtcp-mux\r\n"
|
||||
"a=rtpmap:111 opus/48000/2\r\n"
|
||||
"a=fmtp:111 minptime=10;maxaveragebitrate=98000;stereo=1;sprop-stereo=1;useinbandfec=1\r\n"
|
||||
"a=setup:actpass\r\n"
|
||||
"a=ice-ufrag:fEw/\r\n"
|
||||
"a=ice-pwd:jBua8YGWQKc/Vn6Y9EZ9+0\r\n";
|
||||
|
||||
// Parse the remote SDP
|
||||
HELPER_EXPECT_SUCCESS(ruc->remote_sdp_.parse(ruc->remote_sdp_str_));
|
||||
|
||||
// Create stream description for negotiation output
|
||||
SrsUniquePtr<SrsRtcSourceDescription> stream_desc(new SrsRtcSourceDescription());
|
||||
|
||||
// Test negotiate_publish_capability - typical WebRTC publisher negotiation
|
||||
HELPER_EXPECT_SUCCESS(negotiator->negotiate_publish_capability(ruc.get(), stream_desc.get()));
|
||||
|
||||
// Verify that stream description was populated with audio and video tracks
|
||||
EXPECT_TRUE(stream_desc->audio_track_desc_ != NULL);
|
||||
EXPECT_FALSE(stream_desc->video_track_descs_.empty());
|
||||
EXPECT_EQ("audio", stream_desc->audio_track_desc_->type_);
|
||||
EXPECT_EQ("video", stream_desc->video_track_descs_[0]->type_);
|
||||
|
||||
// Test generate_publish_local_sdp - create answer SDP
|
||||
SrsSdp local_sdp;
|
||||
HELPER_EXPECT_SUCCESS(negotiator->generate_publish_local_sdp(
|
||||
ruc->req_, local_sdp, stream_desc.get(),
|
||||
ruc->remote_sdp_.is_unified(), ruc->audio_before_video_));
|
||||
|
||||
// Verify that local SDP was generated with media descriptions
|
||||
EXPECT_FALSE(local_sdp.media_descs_.empty());
|
||||
|
||||
// Find audio and video media descriptions
|
||||
bool has_audio = false, has_video = false;
|
||||
for (size_t i = 0; i < local_sdp.media_descs_.size(); i++) {
|
||||
if (local_sdp.media_descs_[i].type_ == "audio")
|
||||
has_audio = true;
|
||||
if (local_sdp.media_descs_[i].type_ == "video")
|
||||
has_video = true;
|
||||
}
|
||||
EXPECT_TRUE(has_audio);
|
||||
EXPECT_TRUE(has_video);
|
||||
|
||||
// Test individual SDP generation methods
|
||||
SrsSdp audio_sdp, video_sdp;
|
||||
HELPER_EXPECT_SUCCESS(negotiator->generate_publish_local_sdp_for_audio(audio_sdp, stream_desc.get()));
|
||||
HELPER_EXPECT_SUCCESS(negotiator->generate_publish_local_sdp_for_video(video_sdp, stream_desc.get(), true));
|
||||
|
||||
// Verify audio SDP generation
|
||||
EXPECT_FALSE(audio_sdp.media_descs_.empty());
|
||||
EXPECT_EQ("audio", audio_sdp.media_descs_[0].type_);
|
||||
|
||||
// Verify video SDP generation
|
||||
EXPECT_FALSE(video_sdp.media_descs_.empty());
|
||||
EXPECT_EQ("video", video_sdp.media_descs_[0].type_);
|
||||
}
|
||||
|
||||
VOID TEST(SrsRtcConnectionTest, InitializeTypicalScenario)
|
||||
{
|
||||
srs_error_t err;
|
||||
|
|
|
|||
|
|
@ -1175,3 +1175,139 @@ VOID TEST(AppUtilityTest, IsBoolean)
|
|||
EXPECT_FALSE(srs_is_boolean(""));
|
||||
EXPECT_FALSE(srs_is_boolean("random"));
|
||||
}
|
||||
|
||||
// Test: Parse libdatachannel SDP from issue 4570 and verify fields
|
||||
VOID TEST(SdpTest, ParseLibdatachannelSdpFromIssue4570)
|
||||
{
|
||||
srs_error_t err;
|
||||
|
||||
// SDP from issue 4570 - libdatachannel format with video first, then audio
|
||||
std::string sdp_str =
|
||||
"v=0\r\n"
|
||||
"o=- rtc 4158491451 0 IN IP4 127.0.0.1\r\n"
|
||||
"s=-\r\n"
|
||||
"t=0 0\r\n"
|
||||
"a=group:BUNDLE video audio\r\n"
|
||||
"a=group:LS video audio\r\n"
|
||||
"a=msid-semantic:WMS *\r\n"
|
||||
"a=ice-options:ice2,trickle\r\n"
|
||||
"a=fingerprint:sha-256 28:37:F7:18:77:FC:46:33:6F:B2:0F:12:83:C2:BF:5C:61:5E:96:EB:4B:B9:97:81:92:7C:82:10:97:B8:8E:60\r\n"
|
||||
"m=video 56144 UDP/TLS/RTP/SAVPF 96 97\r\n"
|
||||
"c=IN IP4 172.24.64.1\r\n"
|
||||
"a=mid:video\r\n"
|
||||
"a=sendonly\r\n"
|
||||
"a=ssrc:42 cname:video-send\r\n"
|
||||
"a=rtcp-mux\r\n"
|
||||
"a=rtpmap:96 H264/90000\r\n"
|
||||
"a=rtcp-fb:96 nack\r\n"
|
||||
"a=rtcp-fb:96 nack pli\r\n"
|
||||
"a=rtcp-fb:96 goog-remb\r\n"
|
||||
"a=fmtp:96 profile-level-id=42e01f;packetization-mode=1;level-asymmetry-allowed=1\r\n"
|
||||
"a=rtpmap:97 RTX/90000\r\n"
|
||||
"a=fmtp:97 apt=96\r\n"
|
||||
"a=setup:actpass\r\n"
|
||||
"a=ice-ufrag:fEw/\r\n"
|
||||
"a=ice-pwd:jBua8YGWQKc/Vn6Y9EZ9+0\r\n"
|
||||
"a=candidate:1 1 UDP 2122317823 172.24.64.1 56144 typ host\r\n"
|
||||
"a=candidate:2 1 UDP 2122315767 10.0.0.94 56144 typ host\r\n"
|
||||
"a=candidate:3 1 UDP 1686189695 111.43.134.137 56144 typ srflx raddr 0.0.0.0 rport 0\r\n"
|
||||
"a=end-of-candidates\r\n"
|
||||
"m=audio 56144 UDP/TLS/RTP/SAVPF 111\r\n"
|
||||
"c=IN IP4 172.24.64.1\r\n"
|
||||
"a=mid:audio\r\n"
|
||||
"a=sendonly\r\n"
|
||||
"a=ssrc:43 cname:audio-send\r\n"
|
||||
"a=rtcp-mux\r\n"
|
||||
"a=rtpmap:111 opus/48000/2\r\n"
|
||||
"a=fmtp:111 minptime=10;maxaveragebitrate=98000;stereo=1;sprop-stereo=1;useinbandfec=1\r\n"
|
||||
"a=setup:actpass\r\n"
|
||||
"a=ice-ufrag:fEw/\r\n"
|
||||
"a=ice-pwd:jBua8YGWQKc/Vn6Y9EZ9+0\r\n";
|
||||
|
||||
// Parse the SDP
|
||||
SrsSdp sdp;
|
||||
HELPER_EXPECT_SUCCESS(sdp.parse(sdp_str));
|
||||
|
||||
// Verify session-level fields
|
||||
EXPECT_TRUE(sdp.version_ == "0");
|
||||
EXPECT_TRUE(sdp.group_policy_ == "BUNDLE");
|
||||
EXPECT_TRUE(sdp.groups_.size() == 2);
|
||||
EXPECT_TRUE(sdp.groups_[0] == "video");
|
||||
EXPECT_TRUE(sdp.groups_[1] == "audio");
|
||||
|
||||
// Verify we have 2 media descriptions (video and audio)
|
||||
EXPECT_TRUE(sdp.media_descs_.size() == 2);
|
||||
|
||||
// Verify first media description is video
|
||||
SrsMediaDesc* video_desc = &sdp.media_descs_[0];
|
||||
EXPECT_TRUE(video_desc->type_ == "video");
|
||||
EXPECT_TRUE(video_desc->mid_ == "video");
|
||||
EXPECT_TRUE(video_desc->sendonly_);
|
||||
EXPECT_FALSE(video_desc->recvonly_);
|
||||
EXPECT_TRUE(video_desc->port_ == 56144);
|
||||
EXPECT_TRUE(video_desc->protos_ == "UDP/TLS/RTP/SAVPF");
|
||||
|
||||
// Verify video payload types
|
||||
EXPECT_TRUE(video_desc->payload_types_.size() >= 1);
|
||||
|
||||
// Find H264 payload (PT 96)
|
||||
SrsMediaPayloadType* h264_payload = NULL;
|
||||
for (size_t i = 0; i < video_desc->payload_types_.size(); i++) {
|
||||
if (video_desc->payload_types_[i].payload_type_ == 96) {
|
||||
h264_payload = &video_desc->payload_types_[i];
|
||||
break;
|
||||
}
|
||||
}
|
||||
EXPECT_TRUE(h264_payload != NULL);
|
||||
EXPECT_TRUE(h264_payload->encoding_name_ == "H264");
|
||||
EXPECT_TRUE(h264_payload->clock_rate_ == 90000);
|
||||
|
||||
// Verify video SSRC
|
||||
EXPECT_TRUE(video_desc->ssrc_infos_.size() >= 1);
|
||||
bool found_video_ssrc = false;
|
||||
for (size_t i = 0; i < video_desc->ssrc_infos_.size(); i++) {
|
||||
if (video_desc->ssrc_infos_[i].ssrc_ == 42) {
|
||||
found_video_ssrc = true;
|
||||
EXPECT_TRUE(video_desc->ssrc_infos_[i].cname_ == "video-send");
|
||||
break;
|
||||
}
|
||||
}
|
||||
EXPECT_TRUE(found_video_ssrc);
|
||||
|
||||
// Verify second media description is audio
|
||||
SrsMediaDesc* audio_desc = &sdp.media_descs_[1];
|
||||
EXPECT_TRUE(audio_desc->type_ == "audio");
|
||||
EXPECT_TRUE(audio_desc->mid_ == "audio");
|
||||
EXPECT_TRUE(audio_desc->sendonly_);
|
||||
EXPECT_FALSE(audio_desc->recvonly_);
|
||||
EXPECT_TRUE(audio_desc->port_ == 56144);
|
||||
EXPECT_TRUE(audio_desc->protos_ == "UDP/TLS/RTP/SAVPF");
|
||||
|
||||
// Verify audio payload types
|
||||
EXPECT_TRUE(audio_desc->payload_types_.size() >= 1);
|
||||
|
||||
// Find Opus payload (PT 111)
|
||||
SrsMediaPayloadType* opus_payload = NULL;
|
||||
for (size_t i = 0; i < audio_desc->payload_types_.size(); i++) {
|
||||
if (audio_desc->payload_types_[i].payload_type_ == 111) {
|
||||
opus_payload = &audio_desc->payload_types_[i];
|
||||
break;
|
||||
}
|
||||
}
|
||||
EXPECT_TRUE(opus_payload != NULL);
|
||||
EXPECT_TRUE(opus_payload->encoding_name_ == "opus");
|
||||
EXPECT_TRUE(opus_payload->clock_rate_ == 48000);
|
||||
EXPECT_TRUE(opus_payload->encoding_param_ == "2");
|
||||
|
||||
// Verify audio SSRC
|
||||
EXPECT_TRUE(audio_desc->ssrc_infos_.size() >= 1);
|
||||
bool found_audio_ssrc = false;
|
||||
for (size_t i = 0; i < audio_desc->ssrc_infos_.size(); i++) {
|
||||
if (audio_desc->ssrc_infos_[i].ssrc_ == 43) {
|
||||
found_audio_ssrc = true;
|
||||
EXPECT_TRUE(audio_desc->ssrc_infos_[i].cname_ == "audio-send");
|
||||
break;
|
||||
}
|
||||
}
|
||||
EXPECT_TRUE(found_audio_ssrc);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -316,6 +316,58 @@ std::string MockSdpFactory::create_chrome_publisher_offer_with_g711_pcmu()
|
|||
return ss.str();
|
||||
}
|
||||
|
||||
std::string MockSdpFactory::create_libdatachannel_publisher_offer_with_h264()
|
||||
{
|
||||
// Create a libdatachannel-like WebRTC SDP offer with H.264 video and Opus audio
|
||||
// Key difference from Chrome: video comes first, then audio (libdatachannel order)
|
||||
// This is the actual SDP format from issue 4570
|
||||
std::stringstream ss;
|
||||
ss << "v=0\r\n"
|
||||
<< "o=- rtc 4158491451 0 IN IP4 127.0.0.1\r\n"
|
||||
<< "s=-\r\n"
|
||||
<< "t=0 0\r\n"
|
||||
<< "a=group:BUNDLE video audio\r\n"
|
||||
<< "a=group:LS video audio\r\n"
|
||||
<< "a=msid-semantic:WMS *\r\n"
|
||||
<< "a=ice-options:ice2,trickle\r\n"
|
||||
<< "a=fingerprint:sha-256 28:37:F7:18:77:FC:46:33:6F:B2:0F:12:83:C2:BF:5C:61:5E:96:EB:4B:B9:97:81:92:7C:82:10:97:B8:8E:60\r\n"
|
||||
// Video media description (H.264) - comes first in libdatachannel
|
||||
<< "m=video 56144 UDP/TLS/RTP/SAVPF " << (int)video_pt_ << " 97\r\n"
|
||||
<< "c=IN IP4 172.24.64.1\r\n"
|
||||
<< "a=mid:video\r\n"
|
||||
<< "a=sendonly\r\n"
|
||||
<< "a=ssrc:" << video_ssrc_ << " cname:video-send\r\n"
|
||||
<< "a=rtcp-mux\r\n"
|
||||
<< "a=rtpmap:" << (int)video_pt_ << " H264/90000\r\n"
|
||||
<< "a=rtcp-fb:" << (int)video_pt_ << " nack\r\n"
|
||||
<< "a=rtcp-fb:" << (int)video_pt_ << " nack pli\r\n"
|
||||
<< "a=rtcp-fb:" << (int)video_pt_ << " goog-remb\r\n"
|
||||
<< "a=fmtp:" << (int)video_pt_ << " profile-level-id=42e01f;packetization-mode=1;level-asymmetry-allowed=1\r\n"
|
||||
<< "a=rtpmap:97 RTX/90000\r\n"
|
||||
<< "a=fmtp:97 apt=" << (int)video_pt_ << "\r\n"
|
||||
<< "a=setup:actpass\r\n"
|
||||
<< "a=ice-ufrag:fEw/\r\n"
|
||||
<< "a=ice-pwd:jBua8YGWQKc/Vn6Y9EZ9+0\r\n"
|
||||
<< "a=candidate:1 1 UDP 2122317823 172.24.64.1 56144 typ host\r\n"
|
||||
<< "a=candidate:2 1 UDP 2122315767 10.0.0.94 56144 typ host\r\n"
|
||||
<< "a=candidate:3 1 UDP 1686189695 111.43.134.137 56144 typ srflx raddr 0.0.0.0 rport 0\r\n"
|
||||
<< "a=end-of-candidates\r\n"
|
||||
// Audio media description (Opus) - comes second in libdatachannel
|
||||
<< "m=audio 56144 UDP/TLS/RTP/SAVPF " << (int)audio_pt_ << "\r\n"
|
||||
<< "c=IN IP4 172.24.64.1\r\n"
|
||||
<< "a=mid:audio\r\n"
|
||||
<< "a=sendonly\r\n"
|
||||
<< "a=ssrc:" << audio_ssrc_ << " cname:audio-send\r\n"
|
||||
<< "a=rtcp-mux\r\n"
|
||||
<< "a=rtpmap:" << (int)audio_pt_ << " opus/48000/2\r\n"
|
||||
<< "a=fmtp:" << (int)audio_pt_ << " minptime=10;maxaveragebitrate=98000;stereo=1;sprop-stereo=1;useinbandfec=1\r\n"
|
||||
<< "a=setup:actpass\r\n"
|
||||
<< "a=ice-ufrag:fEw/\r\n"
|
||||
<< "a=ice-pwd:jBua8YGWQKc/Vn6Y9EZ9+0\r\n";
|
||||
|
||||
return ss.str();
|
||||
}
|
||||
|
||||
MockDtlsCertificate::MockDtlsCertificate()
|
||||
{
|
||||
fingerprint_ = "AA:BB:CC:DD:EE:FF:00:11:22:33:44:55:66:77:88:99:AA:BB:CC:DD:EE:FF:00:11:22:33:44:55:66:77:88:99";
|
||||
|
|
|
|||
|
|
@ -87,6 +87,9 @@ public:
|
|||
std::string create_chrome_publisher_offer_with_vp9();
|
||||
// Create a Chrome-like WebRTC publisher offer SDP with G.711 PCMU audio
|
||||
std::string create_chrome_publisher_offer_with_g711_pcmu();
|
||||
// Create a libdatachannel-like WebRTC publisher offer SDP with H.264 video and Opus audio
|
||||
// This mimics the SDP format from libdatachannel library (video first, then audio)
|
||||
std::string create_libdatachannel_publisher_offer_with_h264();
|
||||
};
|
||||
|
||||
// Mock DTLS certificate for testing
|
||||
|
|
|
|||
|
|
@ -37,7 +37,7 @@
|
|||
// This test is used to verify the basic workflow of the RTC connection.
|
||||
// It's finished with the help of AI, but each step is manually designed
|
||||
// and verified. So this is not dominated by AI, but by humanbeing.
|
||||
VOID TEST(BasicWorkflowRtcConnTest, ManuallyVerifyForPlayer)
|
||||
VOID TEST(BasicWorkflowRtcConnTest, WorkflowRtcManuallyVerifyForPlayer)
|
||||
{
|
||||
srs_error_t err;
|
||||
|
||||
|
|
@ -92,6 +92,7 @@ VOID TEST(BasicWorkflowRtcConnTest, ManuallyVerifyForPlayer)
|
|||
|
||||
ruc->remote_sdp_str_ = mock_sdp_factory->create_chrome_player_offer_with_h264();
|
||||
HELPER_EXPECT_SUCCESS(ruc->remote_sdp_.parse(ruc->remote_sdp_str_));
|
||||
EXPECT_TRUE(ruc->remote_sdp_.media_descs_.size() == 2);
|
||||
}
|
||||
|
||||
// Add player, which negotiate the SDP and generate local SDP
|
||||
|
|
@ -187,7 +188,7 @@ VOID TEST(BasicWorkflowRtcConnTest, ManuallyVerifyForPlayer)
|
|||
// This test is used to verify the basic workflow of the RTC connection.
|
||||
// It's finished with the help of AI, but each step is manually designed
|
||||
// and verified. So this is not dominated by AI, but by humanbeing.
|
||||
VOID TEST(BasicWorkflowRtcConnTest, ManuallyVerifyForPublisher)
|
||||
VOID TEST(BasicWorkflowRtcConnTest, WorkflowRtcManuallyVerifyForPublisher)
|
||||
{
|
||||
srs_error_t err;
|
||||
|
||||
|
|
@ -243,6 +244,7 @@ VOID TEST(BasicWorkflowRtcConnTest, ManuallyVerifyForPublisher)
|
|||
|
||||
ruc->remote_sdp_str_ = mock_sdp_factory->create_chrome_publisher_offer_with_h264();
|
||||
HELPER_EXPECT_SUCCESS(ruc->remote_sdp_.parse(ruc->remote_sdp_str_));
|
||||
EXPECT_TRUE(ruc->remote_sdp_.media_descs_.size() == 2);
|
||||
}
|
||||
|
||||
// Add publisher, which negotiate the SDP and generate local SDP
|
||||
|
|
@ -394,10 +396,185 @@ VOID TEST(BasicWorkflowRtcConnTest, ManuallyVerifyForPublisher)
|
|||
publisher->stop();
|
||||
}
|
||||
|
||||
// This test is used to verify the libdatachannel SDP offer from issue 4570.
|
||||
// The issue reports that SRS returns an incomplete SDP answer when receiving
|
||||
// an offer from libdatachannel library.
|
||||
VOID TEST(BasicWorkflowRtcConnTest, WorkflowRtcManuallyVerifyForLibdatachannel)
|
||||
{
|
||||
srs_error_t err;
|
||||
|
||||
// Create mock dependencies FIRST (they must outlive the connection)
|
||||
SrsUniquePtr<MockCircuitBreaker> mock_circuit_breaker(new MockCircuitBreaker());
|
||||
SrsUniquePtr<MockConnectionManager> mock_conn_manager(new MockConnectionManager());
|
||||
SrsUniquePtr<MockRtcSourceManager> mock_rtc_sources(new MockRtcSourceManager());
|
||||
SrsUniquePtr<MockAppConfig> mock_config(new MockAppConfig());
|
||||
SrsUniquePtr<MockDtlsCertificate> mock_dtls_certificate(new MockDtlsCertificate());
|
||||
SrsUniquePtr<MockSdpFactory> mock_sdp_factory(new MockSdpFactory());
|
||||
SrsUniquePtr<MockAppFactoryForRtcConn> mock_app_factory(new MockAppFactoryForRtcConn());
|
||||
SrsStreamPublishTokenManager token_manager;
|
||||
|
||||
mock_config->rtc_dtls_role_ = "passive";
|
||||
mock_dtls_certificate->fingerprint_ = "test-fingerprint";
|
||||
mock_app_factory->rtc_sources_ = mock_rtc_sources.get();
|
||||
mock_app_factory->mock_protocol_utility_ = new MockProtocolUtility("192.168.1.100");
|
||||
MockRtcSource *mock_rtc_source = new MockRtcSource();
|
||||
mock_rtc_sources->mock_source_ = SrsSharedPtr<SrsRtcSource>(mock_rtc_source);
|
||||
|
||||
// Create a real ISrsRtcConnection using _srs_app_factory_
|
||||
MockRtcAsyncTaskExecutor mock_exec;
|
||||
SrsContextId cid;
|
||||
cid.set_value("test-rtc-conn-libdatachannel-workflow");
|
||||
|
||||
SrsUniquePtr<ISrsRtcConnection> conn_ptr(_srs_app_factory->create_rtc_connection(&mock_exec, cid));
|
||||
SrsRtcConnection *conn = dynamic_cast<SrsRtcConnection *>(conn_ptr.get());
|
||||
EXPECT_TRUE(conn != NULL);
|
||||
|
||||
// Mock the RTC conn, also mock the config in publisher_negotiator_ and player_negotiator_
|
||||
conn->circuit_breaker_ = mock_circuit_breaker.get();
|
||||
conn->conn_manager_ = mock_conn_manager.get();
|
||||
conn->rtc_sources_ = mock_rtc_sources.get();
|
||||
conn->config_ = mock_config.get();
|
||||
conn->dtls_certificate_ = mock_dtls_certificate.get();
|
||||
conn->app_factory_ = mock_app_factory.get();
|
||||
|
||||
SrsRtcPublisherNegotiator *pub_neg = dynamic_cast<SrsRtcPublisherNegotiator *>(conn->publisher_negotiator_);
|
||||
pub_neg->config_ = mock_config.get();
|
||||
SrsRtcPlayerNegotiator *play_neg = dynamic_cast<SrsRtcPlayerNegotiator *>(conn->player_negotiator_);
|
||||
play_neg->config_ = mock_config.get();
|
||||
play_neg->rtc_sources_ = mock_rtc_sources.get();
|
||||
|
||||
// Create RTC user config for add_publisher with libdatachannel SDP offer from issue #4570
|
||||
SrsUniquePtr<SrsRtcUserConfig> ruc(new SrsRtcUserConfig());
|
||||
if (true) {
|
||||
srs_freep(ruc->req_);
|
||||
ruc->req_ = new MockRtcAsyncCallRequest("test.vhost", "live", "stream1");
|
||||
ruc->publish_ = true;
|
||||
ruc->dtls_ = true;
|
||||
ruc->srtp_ = true;
|
||||
ruc->audio_before_video_ = false;
|
||||
|
||||
ruc->remote_sdp_str_ = mock_sdp_factory->create_libdatachannel_publisher_offer_with_h264();
|
||||
HELPER_EXPECT_SUCCESS(ruc->remote_sdp_.parse(ruc->remote_sdp_str_));
|
||||
EXPECT_TRUE(ruc->remote_sdp_.media_descs_.size() == 2);
|
||||
}
|
||||
|
||||
// Add publisher, which negotiate the SDP and generate local SDP
|
||||
SrsSdp local_sdp;
|
||||
local_sdp.session_config_.dtls_role_ = mock_config->get_rtc_dtls_role(ruc->req_->vhost_);
|
||||
|
||||
if (true) {
|
||||
HELPER_EXPECT_SUCCESS(conn->add_publisher(ruc.get(), local_sdp));
|
||||
|
||||
// Verify publishers and SSRC mappings
|
||||
EXPECT_TRUE(conn->publishers_.size() == 1);
|
||||
EXPECT_TRUE(conn->publishers_ssrc_map_.size() == 2);
|
||||
EXPECT_TRUE(conn->publishers_ssrc_map_.find(mock_sdp_factory->video_ssrc_) != conn->publishers_ssrc_map_.end());
|
||||
EXPECT_TRUE(conn->publishers_ssrc_map_.find(mock_sdp_factory->audio_ssrc_) != conn->publishers_ssrc_map_.end());
|
||||
|
||||
// Verify the source stream desription, should have two tracks.
|
||||
SrsRtcSourceDescription *stream_desc = mock_rtc_sources->mock_source_->stream_desc_;
|
||||
EXPECT_TRUE(stream_desc->audio_track_desc_ != NULL);
|
||||
EXPECT_TRUE(stream_desc->video_track_descs_.size() == 1);
|
||||
|
||||
// Verify the audio track ssrc and payload type.
|
||||
EXPECT_TRUE(stream_desc->audio_track_desc_->ssrc_ == mock_sdp_factory->audio_ssrc_);
|
||||
EXPECT_TRUE(stream_desc->audio_track_desc_->media_->pt_ == mock_sdp_factory->audio_pt_);
|
||||
|
||||
// Verify the video track ssrc and payload type.
|
||||
EXPECT_TRUE(stream_desc->video_track_descs_[0]->ssrc_ == mock_sdp_factory->video_ssrc_);
|
||||
EXPECT_TRUE(stream_desc->video_track_descs_[0]->media_->pt_ == mock_sdp_factory->video_pt_);
|
||||
|
||||
// Verify the local SDP was generated with media information
|
||||
EXPECT_TRUE(local_sdp.version_ == "0");
|
||||
EXPECT_TRUE(local_sdp.group_policy_ == "BUNDLE");
|
||||
EXPECT_TRUE(local_sdp.msids_.size() == 1);
|
||||
EXPECT_TRUE(local_sdp.msids_[0] == "live/stream1");
|
||||
EXPECT_TRUE(local_sdp.media_descs_.size() == 2);
|
||||
|
||||
// First should be video media desc (libdatachannel puts video first)
|
||||
SrsMediaDesc *video_desc = &local_sdp.media_descs_[0];
|
||||
EXPECT_TRUE(video_desc->type_ == "video");
|
||||
EXPECT_TRUE(video_desc->recvonly_);
|
||||
EXPECT_TRUE(video_desc->payload_types_.size() >= 1);
|
||||
EXPECT_TRUE(video_desc->payload_types_[0].payload_type_ == mock_sdp_factory->video_pt_);
|
||||
EXPECT_TRUE(video_desc->payload_types_[0].encoding_name_ == "H264");
|
||||
EXPECT_TRUE(video_desc->payload_types_[0].clock_rate_ == 90000);
|
||||
|
||||
// Second should be audio media desc
|
||||
SrsMediaDesc *audio_desc = &local_sdp.media_descs_[1];
|
||||
EXPECT_TRUE(audio_desc->type_ == "audio");
|
||||
EXPECT_TRUE(audio_desc->recvonly_);
|
||||
EXPECT_TRUE(audio_desc->payload_types_.size() == 1);
|
||||
EXPECT_TRUE(audio_desc->payload_types_[0].payload_type_ == mock_sdp_factory->audio_pt_);
|
||||
EXPECT_TRUE(audio_desc->payload_types_[0].encoding_name_ == "opus");
|
||||
EXPECT_TRUE(audio_desc->payload_types_[0].clock_rate_ == 48000);
|
||||
}
|
||||
|
||||
// Generate local SDP and setup SDP.
|
||||
std::string username;
|
||||
if (true) {
|
||||
bool status = true;
|
||||
conn->set_all_tracks_status(ruc->req_->get_stream_url(), ruc->publish_, status);
|
||||
|
||||
HELPER_EXPECT_SUCCESS(conn->generate_local_sdp(ruc.get(), local_sdp, username));
|
||||
conn->set_remote_sdp(ruc->remote_sdp_);
|
||||
conn->set_local_sdp(local_sdp);
|
||||
conn->set_state_as_waiting_stun();
|
||||
|
||||
// Verify the local SDP was generated ice pwd
|
||||
SrsMediaDesc *video_desc = &local_sdp.media_descs_[0];
|
||||
EXPECT_TRUE(!video_desc->session_info_.ice_pwd_.empty());
|
||||
EXPECT_TRUE(!video_desc->session_info_.fingerprint_.empty());
|
||||
EXPECT_TRUE(video_desc->candidates_.size() == 1);
|
||||
EXPECT_TRUE(video_desc->candidates_[0].ip_ == "192.168.1.100");
|
||||
EXPECT_TRUE(video_desc->session_info_.setup_ == "passive");
|
||||
|
||||
SrsMediaDesc *audio_desc = &local_sdp.media_descs_[1];
|
||||
EXPECT_TRUE(!audio_desc->session_info_.ice_pwd_.empty());
|
||||
EXPECT_TRUE(!audio_desc->session_info_.fingerprint_.empty());
|
||||
EXPECT_TRUE(audio_desc->candidates_.size() == 1);
|
||||
EXPECT_TRUE(audio_desc->candidates_[0].ip_ == "192.168.1.100");
|
||||
EXPECT_TRUE(audio_desc->session_info_.setup_ == "passive");
|
||||
|
||||
EXPECT_TRUE(local_sdp.session_negotiate_.dtls_role_ == "passive");
|
||||
}
|
||||
|
||||
// Initialize the connection
|
||||
if (true) {
|
||||
HELPER_EXPECT_SUCCESS(conn->initialize(ruc->req_, ruc->dtls_, ruc->srtp_, username));
|
||||
EXPECT_TRUE(conn->nack_enabled_);
|
||||
|
||||
// Create and set publish token
|
||||
SrsStreamPublishToken *publish_token_raw = NULL;
|
||||
HELPER_EXPECT_SUCCESS(token_manager.acquire_token(ruc->req_, publish_token_raw));
|
||||
SrsSharedPtr<ISrsStreamPublishToken> publish_token(publish_token_raw);
|
||||
|
||||
conn->set_publish_token(publish_token);
|
||||
EXPECT_TRUE(conn->publish_token_->is_acquired());
|
||||
}
|
||||
|
||||
// DTLS done, start publisher
|
||||
SrsRtcPublishStream *publisher = NULL;
|
||||
if (true) {
|
||||
HELPER_EXPECT_SUCCESS(conn->on_dtls_handshake_done());
|
||||
|
||||
// Wait for coroutine to start. Normally it should be ready wait for PLI requests.
|
||||
srs_usleep(1 * SRS_UTIME_MILLISECONDS);
|
||||
|
||||
// Verify the publisher is created and started
|
||||
EXPECT_TRUE(conn->publishers_.size() == 1);
|
||||
publisher = dynamic_cast<SrsRtcPublishStream *>(conn->publishers_.begin()->second);
|
||||
EXPECT_TRUE(publisher->is_sender_started_);
|
||||
}
|
||||
|
||||
// Stop the publisher
|
||||
publisher->stop();
|
||||
}
|
||||
|
||||
// This test is used to verify the basic workflow of the RTC connection with AV1 codec.
|
||||
// It's finished with the help of AI, but each step is manually designed
|
||||
// and verified. So this is not dominated by AI, but by humanbeing.
|
||||
VOID TEST(BasicWorkflowRtcConnTest, ManuallyVerifyForPublisherWithAV1)
|
||||
VOID TEST(BasicWorkflowRtcConnTest, WorkflowRtcManuallyVerifyForPublisherWithAV1)
|
||||
{
|
||||
srs_error_t err;
|
||||
|
||||
|
|
@ -454,6 +631,7 @@ VOID TEST(BasicWorkflowRtcConnTest, ManuallyVerifyForPublisherWithAV1)
|
|||
|
||||
ruc->remote_sdp_str_ = mock_sdp_factory->create_chrome_publisher_offer_with_av1();
|
||||
HELPER_EXPECT_SUCCESS(ruc->remote_sdp_.parse(ruc->remote_sdp_str_));
|
||||
EXPECT_TRUE(ruc->remote_sdp_.media_descs_.size() == 2);
|
||||
}
|
||||
|
||||
// Add publisher, which negotiate the SDP and generate local SDP
|
||||
|
|
@ -612,7 +790,7 @@ VOID TEST(BasicWorkflowRtcConnTest, ManuallyVerifyForPublisherWithAV1)
|
|||
// This test is used to verify the basic workflow of the RTC connection with VP9 codec.
|
||||
// It's finished with the help of AI, but each step is manually designed
|
||||
// and verified. So this is not dominated by AI, but by humanbeing.
|
||||
VOID TEST(BasicWorkflowRtcConnTest, ManuallyVerifyForPublisherWithVP9)
|
||||
VOID TEST(BasicWorkflowRtcConnTest, WorkflowRtcManuallyVerifyForPublisherWithVP9)
|
||||
{
|
||||
srs_error_t err;
|
||||
|
||||
|
|
@ -669,6 +847,7 @@ VOID TEST(BasicWorkflowRtcConnTest, ManuallyVerifyForPublisherWithVP9)
|
|||
|
||||
ruc->remote_sdp_str_ = mock_sdp_factory->create_chrome_publisher_offer_with_vp9();
|
||||
HELPER_EXPECT_SUCCESS(ruc->remote_sdp_.parse(ruc->remote_sdp_str_));
|
||||
EXPECT_TRUE(ruc->remote_sdp_.media_descs_.size() == 2);
|
||||
}
|
||||
|
||||
// Add publisher, which negotiate the SDP and generate local SDP
|
||||
|
|
@ -827,7 +1006,7 @@ VOID TEST(BasicWorkflowRtcConnTest, ManuallyVerifyForPublisherWithVP9)
|
|||
// This test is used to verify the basic workflow of the RTC connection with G.711 PCMU codec.
|
||||
// It's finished with the help of AI, but each step is manually designed
|
||||
// and verified. So this is not dominated by AI, but by humanbeing.
|
||||
VOID TEST(BasicWorkflowRtcConnTest, ManuallyVerifyForPublisherWithG711Pcmu)
|
||||
VOID TEST(BasicWorkflowRtcConnTest, WorkflowRtcManuallyVerifyForPublisherWithG711Pcmu)
|
||||
{
|
||||
srs_error_t err;
|
||||
|
||||
|
|
@ -884,6 +1063,7 @@ VOID TEST(BasicWorkflowRtcConnTest, ManuallyVerifyForPublisherWithG711Pcmu)
|
|||
|
||||
ruc->remote_sdp_str_ = mock_sdp_factory->create_chrome_publisher_offer_with_g711_pcmu();
|
||||
HELPER_EXPECT_SUCCESS(ruc->remote_sdp_.parse(ruc->remote_sdp_str_));
|
||||
EXPECT_TRUE(ruc->remote_sdp_.media_descs_.size() == 2);
|
||||
}
|
||||
|
||||
// Add publisher, which negotiate the SDP and generate local SDP
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user