diff --git a/trunk/doc/CHANGELOG.md b/trunk/doc/CHANGELOG.md
index 7c0ad9f30..a7bcb2ed1 100644
--- a/trunk/doc/CHANGELOG.md
+++ b/trunk/doc/CHANGELOG.md
@@ -7,6 +7,7 @@ The changelog for SRS.
## SRS 7.0 Changelog
+* v7.0, 2025-12-03, AI: WebRTC: Fix audio-only WHIP publish without SSRC. v7.0.132 (#4570)
* v7.0, 2025-11-30, SRT: Support default_mode config for short streamid format. v7.0.131
* v7.0, 2025-11-28, SRT: Fix player not exiting when publisher disconnects. v7.0.130 (#4591)
* v7.0, 2025-11-27, Merge [#4588](https://github.com/ossrs/srs/pull/4588): RTMP: Ignore FMLE start packet after flash publish. v7.0.129 (#4588)
diff --git a/trunk/src/app/srs_app_rtc_conn.cpp b/trunk/src/app/srs_app_rtc_conn.cpp
index 6e0ff2fb6..659d2de0d 100644
--- a/trunk/src/app/srs_app_rtc_conn.cpp
+++ b/trunk/src/app/srs_app_rtc_conn.cpp
@@ -3632,6 +3632,26 @@ srs_error_t SrsRtcPublisherNegotiator::negotiate_publish_capability(SrsRtcUserCo
track_id = msid_tracker;
}
+ // Handle case where no SSRC info is present in the offer (e.g., libdatachannel audio-only).
+ // We still need to create track description to generate proper SDP answer.
+ // See https://github.com/paullouisageneau/libdatachannel which may not include SSRC.
+ // See https://github.com/ossrs/srs/issues/4570#issuecomment-3604598513
+ if (remote_media_desc.ssrc_infos_.empty()) {
+ SrsRtcTrackDescription *track_desc_copy = track_desc->copy();
+ // Generate synthetic values since no SSRC info provided.
+ track_desc_copy->ssrc_ = 0;
+ track_desc_copy->id_ = srs_fmt_sprintf("track-%s-%s", track_desc->type_.c_str(), remote_media_desc.mid_.c_str());
+ track_desc_copy->msid_ = req->app_ + "/" + req->stream_;
+
+ if (remote_media_desc.is_audio() && !stream_desc->audio_track_desc_) {
+ stream_desc->audio_track_desc_ = track_desc_copy;
+ } else if (remote_media_desc.is_video()) {
+ stream_desc->video_track_descs_.push_back(track_desc_copy);
+ } else {
+ srs_freep(track_desc_copy);
+ }
+ }
+
// set track fec_ssrc and rtx_ssrc
for (int j = 0; j < (int)remote_media_desc.ssrc_groups_.size(); ++j) {
const SrsSSRCGroup &ssrc_group = remote_media_desc.ssrc_groups_.at(j);
diff --git a/trunk/src/core/srs_core_version7.hpp b/trunk/src/core/srs_core_version7.hpp
index 33b21501d..a47da7a15 100644
--- a/trunk/src/core/srs_core_version7.hpp
+++ b/trunk/src/core/srs_core_version7.hpp
@@ -9,6 +9,6 @@
#define VERSION_MAJOR 7
#define VERSION_MINOR 0
-#define VERSION_REVISION 131
+#define VERSION_REVISION 132
#endif
\ No newline at end of file
diff --git a/trunk/src/utest/srs_utest_ai12.cpp b/trunk/src/utest/srs_utest_ai12.cpp
index a5b26e953..107ae19ab 100644
--- a/trunk/src/utest/srs_utest_ai12.cpp
+++ b/trunk/src/utest/srs_utest_ai12.cpp
@@ -2149,6 +2149,91 @@ VOID TEST(SrsRtcPublisherNegotiatorTest, LibdatachannelUseScenario)
EXPECT_EQ("video", video_sdp.media_descs_[0].type_);
}
+// Test audio-only libdatachannel scenario WITHOUT SSRC info.
+// This test demonstrates the bug where libdatachannel fails with:
+// "Remote description has no ICE user fragment"
+// Root cause: When the offer SDP has no a=ssrc: line, stream_desc->audio_track_desc_
+// is never set, so generate_publish_local_sdp_for_audio() doesn't add the m=audio
+// section to the answer SDP.
+VOID TEST(SrsRtcPublisherNegotiatorTest, LibdatachannelAudioOnlyWithoutSsrc)
+{
+ srs_error_t err;
+
+ // Create SrsRtcPublisherNegotiator
+ SrsUniquePtr negotiator(new SrsRtcPublisherNegotiator());
+
+ // Create mock request for initialization
+ SrsUniquePtr mock_request(new MockRtcConnectionRequest("test.vhost", "live", "voice_stream"));
+
+ // Create mock RTC user config with remote SDP
+ SrsUniquePtr ruc(new SrsRtcUserConfig());
+ ruc->req_ = mock_request->copy();
+ ruc->publish_ = true;
+ ruc->dtls_ = true;
+ ruc->srtp_ = true;
+ ruc->audio_before_video_ = true;
+
+ // Audio-only SDP from libdatachannel - NO SSRC LINE (this is the key difference!)
+ // This matches the actual user-reported SDP that causes the bug
+ ruc->remote_sdp_str_ =
+ "v=0\r\n"
+ "o=rtc 4107523824 0 IN IP4 127.0.0.1\r\n"
+ "s=-\r\n"
+ "t=0 0\r\n"
+ "a=group:BUNDLE audio\r\n"
+ "a=group:LS audio\r\n"
+ "a=msid-semantic:WMS *\r\n"
+ "a=ice-options:ice2,trickle\r\n"
+ "a=fingerprint:sha-256 C3:22:A4:0D:46:6C:8C:3E:3B:05:59:63:C3:8A:43:97:30:4C:3E:5F:01:BA:C9:77:AC:10:89:A7:83:BA:21:08\r\n"
+ "m=audio 36954 UDP/TLS/RTP/SAVPF 111\r\n"
+ "c=IN IP4 192.168.1.100\r\n"
+ "a=mid:audio\r\n"
+ "a=sendonly\r\n"
+ "a=rtcp-mux\r\n"
+ "a=rtpmap:111 opus/48000/2\r\n"
+ "a=fmtp:111 minptime=10;maxaveragebitrate=96000;stereo=1;sprop-stereo=1;useinbandfec=1\r\n"
+ "a=setup:actpass\r\n"
+ "a=ice-ufrag:rUic\r\n"
+ "a=ice-pwd:76ZWO/4FkRx6r2nMUF8yeH\r\n"
+ // NOTE: No a=ssrc: line here - this is the bug trigger!
+ "a=candidate:1 1 UDP 2114977791 192.168.1.100 36954 typ host\r\n"
+ "a=end-of-candidates\r\n";
+
+ // Parse the remote SDP
+ HELPER_EXPECT_SUCCESS(ruc->remote_sdp_.parse(ruc->remote_sdp_str_));
+
+ // Verify only audio media description is present
+ EXPECT_EQ(1u, ruc->remote_sdp_.media_descs_.size());
+ EXPECT_EQ("audio", ruc->remote_sdp_.media_descs_[0].type_);
+
+ // Verify NO SSRC info in the parsed SDP (this is the bug condition)
+ EXPECT_TRUE(ruc->remote_sdp_.media_descs_[0].ssrc_infos_.empty());
+
+ // Create stream description for negotiation output
+ SrsUniquePtr stream_desc(new SrsRtcSourceDescription());
+
+ // Test negotiate_publish_capability - this should work but audio_track_desc_ will be NULL
+ HELPER_EXPECT_SUCCESS(negotiator->negotiate_publish_capability(ruc.get(), stream_desc.get()));
+
+ // BUG: audio_track_desc_ is NULL because there's no SSRC info in the offer
+ // This causes generate_publish_local_sdp_for_audio() to not add m=audio section
+ EXPECT_TRUE(stream_desc->audio_track_desc_ != NULL) << "BUG: audio_track_desc_ should not be NULL for audio-only SDP without SSRC";
+ EXPECT_TRUE(stream_desc->video_track_descs_.empty());
+
+ // 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_));
+
+ // BUG: local_sdp.media_descs_ is empty because audio_track_desc_ was NULL
+ // This causes the answer SDP to have no m=audio line, which makes libdatachannel fail
+ EXPECT_EQ(1u, local_sdp.media_descs_.size()) << "BUG: Answer SDP should have m=audio section";
+ if (!local_sdp.media_descs_.empty()) {
+ EXPECT_EQ("audio", local_sdp.media_descs_[0].type_);
+ }
+}
+
VOID TEST(SrsRtcConnectionTest, InitializeTypicalScenario)
{
srs_error_t err;