From 63a96b833b9f5f9a03770dce7cf56bfc1d8c1bbb Mon Sep 17 00:00:00 2001 From: kirill-jjj Date: Tue, 8 Sep 2026 21:23:37 +0500 Subject: [PATCH 1/4] feat(audio): negotiate stereo opus on the subscriber answer MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds stereo=1 to the Opus fmtp line of the subscriber answer for media sections where the server offer advertised sprop-stereo=1, so stereo tracks published by other participants are not downmixed to mono. Falls back to the un-munged answer if setLocalDescription fails. Subscriber-only scope; publisher-side stereo options to follow separately after useStereoInput/useStereoOutput exposure in webrtc-sdk/webrtc. Generated with Codebuff 🤖 Co-Authored-By: Codebuff --- .changeset/stereo-answer-munging.md | 5 + .../java/io/livekit/android/room/RTCEngine.kt | 26 ++- .../android/webrtc/StereoSdpMunging.kt | 152 ++++++++++++++++++ .../android/webrtc/StereoSdpMungingTest.kt | 146 +++++++++++++++++ 4 files changed, 325 insertions(+), 4 deletions(-) create mode 100644 .changeset/stereo-answer-munging.md create mode 100644 livekit-android-sdk/src/main/java/io/livekit/android/webrtc/StereoSdpMunging.kt create mode 100644 livekit-android-test/src/test/java/io/livekit/android/webrtc/StereoSdpMungingTest.kt diff --git a/.changeset/stereo-answer-munging.md b/.changeset/stereo-answer-munging.md new file mode 100644 index 000000000..706ae7289 --- /dev/null +++ b/.changeset/stereo-answer-munging.md @@ -0,0 +1,5 @@ +--- +'livekit-android': patch +--- + +Negotiate stereo Opus on the subscriber answer: add `stereo=1` to the fmtp line for media sections where the server offer advertised `sprop-stereo=1`. Without this, a stereo track published by another participant is decoded as mono on Android. Mirrors client-sdk-js behavior. diff --git a/livekit-android-sdk/src/main/java/io/livekit/android/room/RTCEngine.kt b/livekit-android-sdk/src/main/java/io/livekit/android/room/RTCEngine.kt index e0e64e7db..cce94f595 100644 --- a/livekit-android-sdk/src/main/java/io/livekit/android/room/RTCEngine.kt +++ b/livekit-android-sdk/src/main/java/io/livekit/android/room/RTCEngine.kt @@ -55,6 +55,7 @@ import io.livekit.android.webrtc.DataPacketBuffer import io.livekit.android.webrtc.DataPacketItem import io.livekit.android.webrtc.RTCStatsGetter import io.livekit.android.webrtc.copy +import io.livekit.android.webrtc.ensureStereoOpus import io.livekit.android.webrtc.isConnected import io.livekit.android.webrtc.isDisconnected import io.livekit.android.webrtc.peerconnection.RTCThreadToken @@ -1158,20 +1159,37 @@ internal constructor( return@launch } - run { - when (val outcome = subscriber?.withPeerConnection { setLocalDescription(answer) }.nullSafe()) { + val stereoAnswer = answer.ensureStereoOpus(sessionDescription) + + val shouldFallback = run { + when (val outcome = subscriber?.withPeerConnection { setLocalDescription(stereoAnswer) }.nullSafe()) { + is Either.Left -> false + is Either.Right -> { + LKLog.e { "error setting local description for munged answer: ${outcome.value}" } + true + } + } + } + + if (shouldFallback) { + // Fall back to the un-munged answer rather than leaving the + // subscriber without a local description (mirrors + // PeerConnectionTransport.setMungedSdp). + when (val fallback = subscriber?.withPeerConnection { setLocalDescription(answer) }.nullSafe()) { is Either.Left -> Unit is Either.Right -> { - LKLog.e { "error setting local description for answer: ${outcome.value}" } + LKLog.e { "error setting local description for answer: ${fallback.value}" } return@launch } } + client.sendAnswer(answer, offerId) + return@launch } if (isClosed) { return@launch } - client.sendAnswer(answer, offerId) + client.sendAnswer(stereoAnswer, offerId) } } diff --git a/livekit-android-sdk/src/main/java/io/livekit/android/webrtc/StereoSdpMunging.kt b/livekit-android-sdk/src/main/java/io/livekit/android/webrtc/StereoSdpMunging.kt new file mode 100644 index 000000000..7ea6a2f32 --- /dev/null +++ b/livekit-android-sdk/src/main/java/io/livekit/android/webrtc/StereoSdpMunging.kt @@ -0,0 +1,152 @@ +/* + * Copyright 2023-2026 LiveKit, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.livekit.android.webrtc + +import android.javax.sdp.MediaDescription +import android.javax.sdp.SdpException +import android.javax.sdp.SdpFactory +import android.javax.sdp.SdpParseException +import androidx.annotation.VisibleForTesting +import io.livekit.android.util.LKLog +import livekit.org.webrtc.SessionDescription + +private const val OPUS_CODEC = "opus" +private const val STEREO_FMTP_PARAM = "stereo=1" +private const val SPROP_STEREO_FMTP_PARAM = "sprop-stereo=1" +private const val MID_ATTRIBUTE = "mid" + +/** + * Adds `stereo=1` to the Opus fmtp line of the answer for every audio media + * section where [offer] advertised `sprop-stereo=1`. + * + * The native Opus decoder downmixes incoming stereo packets to mono unless the + * local answer negotiates `stereo=1`, so without this munging a stereo track + * published by another participant is received as mono. + * + * Mirrors the behavior of client-sdk-js, which extracts `remoteStereoMids` from + * the server offer and rewrites the matching fmtp lines when creating the answer. + * + * @suppress + */ +@VisibleForTesting +internal fun SessionDescription.ensureStereoOpus(offer: SessionDescription): SessionDescription { + val sdpFactory = SdpFactory.getInstance() + val parsedAnswer = parseSessionDescription(sdpFactory, description) ?: return this + val parsedOffer = parseSessionDescription(sdpFactory, offer.description) ?: return this + + val stereoMids = findStereoMids(parsedOffer) + if (stereoMids.isEmpty()) { + return this + } + + for (mediaDesc in mediaDescriptionsOf(parsedAnswer)) { + val mid = midOf(mediaDesc) ?: continue + if (mid !in stereoMids) continue + + val payloadType = findOpusPayloadType(mediaDesc) ?: continue + ensureStereoFmtpParam(mediaDesc, payloadType) + } + + return try { + SessionDescription(type, parsedAnswer.toString()) + } catch (_: SdpException) { + this + } +} + +private fun parseSessionDescription( + sdpFactory: SdpFactory, + description: String, +): android.javax.sdp.SessionDescription? = try { + sdpFactory.createSessionDescription(description) +} catch (_: SdpParseException) { + LKLog.w { "stereo munging: could not parse sdp" } + null +} + +private fun mediaDescriptionsOf(parsed: android.javax.sdp.SessionDescription): List { + val raw = try { + parsed.getMediaDescriptions(true) + } catch (_: SdpException) { + return emptyList() + } + return raw.filterIsInstance() +} + +private fun findStereoMids(offer: android.javax.sdp.SessionDescription): List { + val mids = mutableListOf() + for (mediaDesc in mediaDescriptionsOf(offer)) { + if (isPublisherStereo(mediaDesc)) { + midOf(mediaDesc)?.let { mids.add(it) } + } + } + return mids +} + +private fun midOf(mediaDesc: MediaDescription): String? = try { + mediaDesc.getAttribute(MID_ATTRIBUTE) +} catch (_: SdpParseException) { + null +} + +private fun isPublisherStereo(mediaDesc: MediaDescription): Boolean { + val payloadType = findOpusPayloadType(mediaDesc) ?: return false + for ((_, fmtp) in mediaDesc.getFmtps()) { + if (fmtp.payload == payloadType && fmtp.config.split(";").any { it.trim() == SPROP_STEREO_FMTP_PARAM }) { + return true + } + } + return false +} + +private fun findOpusPayloadType(mediaDesc: MediaDescription): Long? { + for ((_, rtp) in mediaDesc.getRtps()) { + if (rtp.codec.equals(OPUS_CODEC, ignoreCase = true)) { + return rtp.payload + } + } + return null +} + +/* The native Opus decoder requires both sides of the negotiation to carry +stereo=1. The server only puts sprop-stereo=1 into its offer; the answer must +add the stereo=1 parameter itself or received packets are decoded as mono. +*/ +private fun ensureStereoFmtpParam(mediaDesc: MediaDescription, payloadType: Long) { + var fmtpFound = false + for ((attribute, fmtp) in mediaDesc.getFmtps()) { + if (fmtp.payload != payloadType) { + continue + } + fmtpFound = true + if (!fmtp.config.split(";").any { it.trim() == STEREO_FMTP_PARAM }) { + try { + attribute.setValue("${fmtp.payload} ${fmtp.config};$STEREO_FMTP_PARAM") + } catch (_: SdpException) { + LKLog.w { "stereo munging: failed to update opus fmtp line" } + } + } + break + } + + // Not found, add manually + if (!fmtpFound) { + mediaDesc.addAttribute( + SdpFmtp(payloadType, STEREO_FMTP_PARAM).toAttributeField(), + ) + } +} diff --git a/livekit-android-test/src/test/java/io/livekit/android/webrtc/StereoSdpMungingTest.kt b/livekit-android-test/src/test/java/io/livekit/android/webrtc/StereoSdpMungingTest.kt new file mode 100644 index 000000000..264fd6313 --- /dev/null +++ b/livekit-android-test/src/test/java/io/livekit/android/webrtc/StereoSdpMungingTest.kt @@ -0,0 +1,146 @@ +/* + * Copyright 2023-2026 LiveKit, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.livekit.android.webrtc + +import android.javax.sdp.MediaDescription +import android.javax.sdp.SdpFactory +import livekit.org.webrtc.SessionDescription +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test + +class StereoSdpMungingTest { + + private val sdpFactory = SdpFactory.getInstance() + + @Test + fun ensureStereoOpusAddsStereoWhenOfferHasSpropStereo() { + val answer = answerOf(STEREO_OFFER_DESCRIPTION) + val offer = offerOf(STEREO_OFFER_DESCRIPTION) + + val munged = answer.ensureStereoOpus(offer) + + val audioMedia = audioMid(munged, "1") + val fmtp = audioMedia.getFmtps().first { (_, fmtp) -> fmtp.payload == 111L } + assertTrue(fmtp.second.config.split(";").any { it.trim() == "stereo=1" }) + } + + @Test + fun ensureStereoOpusDoesNothingWhenOfferLacksSpropStereo() { + val answer = answerOf(NO_STEREO_OFFER_DESCRIPTION) + val offer = offerOf(NO_STEREO_OFFER_DESCRIPTION) + + val munged = answer.ensureStereoOpus(offer) + + assertEquals(NO_STEREO_OFFER_DESCRIPTION, munged.description) + } + + @Test + fun ensureStereoOpusDoesNotDuplicateStereoParam() { + val answer = answerOf(ANSWER_WITH_STEREO_DESCRIPTION) + val offer = offerOf(STEREO_OFFER_DESCRIPTION) + + val munged = answer.ensureStereoOpus(offer) + + val audioMedia = audioMid(munged, "1") + val stereoFmtps = audioMedia.getFmtps() + .filter { (_, fmtp) -> + fmtp.payload == 111L && fmtp.config.split(";").any { it.trim() == "stereo=1" } + } + assertEquals(1, stereoFmtps.size) + } + + private fun answerOf(sdp: String): SessionDescription = + SessionDescription(SessionDescription.Type.ANSWER, sdp) + + private fun offerOf(sdp: String): SessionDescription = + SessionDescription(SessionDescription.Type.OFFER, sdp) + + private fun audioMid(munged: SessionDescription, mid: String): MediaDescription = + sdpFactory.createSessionDescription(munged.description) + .getMediaDescriptions(true) + .filterIsInstance() + .first { it.getAttribute("mid") == mid } + + companion object { + // Mirrors a LiveKit subscriber offer: audio mid "1" carries opus with + // sprop-stereo=1 (publisher published a stereo track). + private const val STEREO_OFFER_DESCRIPTION = "v=0\r\n" + + "o=- 8980856298632007851 1787470315 IN IP4 0.0.0.0\r\n" + + "s=-\r\n" + + "t=0 0\r\n" + + "a=msid-semantic:WMS *\r\n" + + "a=group:BUNDLE 0 1\r\n" + + "m=application 9 UDP/DTLS/SCTP webrtc-datachannel\r\n" + + "c=IN IP4 0.0.0.0\r\n" + + "a=mid:0\r\n" + + "a=sendrecv\r\n" + + "a=sctp-port:5000\r\n" + + "m=audio 9 UDP/TLS/RTP/SAVPF 111 63\r\n" + + "c=IN IP4 0.0.0.0\r\n" + + "a=mid:1\r\n" + + "a=rtcp-mux\r\n" + + "a=rtpmap:111 opus/48000/2\r\n" + + "a=fmtp:111 minptime=10;useinbandfec=1;sprop-stereo=1\r\n" + + "a=rtpmap:63 red/48000/2\r\n" + + "a=fmtp:63 111/111\r\n" + + "a=recvonly\r\n" + + // Publisher published a mono track: no sprop-stereo anywhere. + private const val NO_STEREO_OFFER_DESCRIPTION = "v=0\r\n" + + "o=- 8980856298632007851 1787470315 IN IP4 0.0.0.0\r\n" + + "s=-\r\n" + + "t=0 0\r\n" + + "a=msid-semantic:WMS *\r\n" + + "a=group:BUNDLE 0 1\r\n" + + "m=application 9 UDP/DTLS/SCTP webrtc-datachannel\r\n" + + "c=IN IP4 0.0.0.0\r\n" + + "a=mid:0\r\n" + + "a=sendrecv\r\n" + + "a=sctp-port:5000\r\n" + + "m=audio 9 UDP/TLS/RTP/SAVPF 111 63\r\n" + + "c=IN IP4 0.0.0.0\r\n" + + "a=mid:1\r\n" + + "a=rtcp-mux\r\n" + + "a=rtpmap:111 opus/48000/2\r\n" + + "a=fmtp:111 minptime=10;useinbandfec=1\r\n" + + "a=rtpmap:63 red/48000/2\r\n" + + "a=fmtp:63 111/111\r\n" + + "a=recvonly\r\n" + + // The answer already negotiated stereo=1: munging must not duplicate it. + private const val ANSWER_WITH_STEREO_DESCRIPTION = "v=0\r\n" + + "o=- 3119613797835240840 4 IN IP4 127.0.0.1\r\n" + + "s=-\r\n" + + "t=0 0\r\n" + + "a=group:BUNDLE 0 1\r\n" + + "a=msid-semantic: WMS\r\n" + + "m=application 9 UDP/DTLS/SCTP webrtc-datachannel\r\n" + + "c=IN IP4 0.0.0.0\r\n" + + "a=mid:0\r\n" + + "a=sctp-port:5000\r\n" + + "m=audio 9 UDP/TLS/RTP/SAVPF 111 63\r\n" + + "c=IN IP4 0.0.0.0\r\n" + + "a=mid:1\r\n" + + "a=rtcp-mux\r\n" + + "a=rtpmap:111 opus/48000/2\r\n" + + "a=fmtp:111 minptime=10;useinbandfec=1;stereo=1\r\n" + + "a=rtpmap:63 red/48000/2\r\n" + + "a=fmtp:63 111/111\r\n" + + "a=recvonly\r\n" + } +} From 04925ce01af9a77da8859acd63d1971cbbf9bb69 Mon Sep 17 00:00:00 2001 From: kirill-jjj Date: Tue, 8 Sep 2026 21:49:48 +0500 Subject: [PATCH 2/4] refactor: extract stereo fallback into helper MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit onServerOffer exceeded detekt's cyclomatic complexity threshold (16 > 15) after adding the fallback path. Generated with Codebuff 🤖 Co-Authored-By: Codebuff --- .../java/io/livekit/android/room/RTCEngine.kt | 50 +++++++++---------- 1 file changed, 25 insertions(+), 25 deletions(-) diff --git a/livekit-android-sdk/src/main/java/io/livekit/android/room/RTCEngine.kt b/livekit-android-sdk/src/main/java/io/livekit/android/room/RTCEngine.kt index cce94f595..4c5180cdc 100644 --- a/livekit-android-sdk/src/main/java/io/livekit/android/room/RTCEngine.kt +++ b/livekit-android-sdk/src/main/java/io/livekit/android/room/RTCEngine.kt @@ -1161,38 +1161,38 @@ internal constructor( val stereoAnswer = answer.ensureStereoOpus(sessionDescription) - val shouldFallback = run { - when (val outcome = subscriber?.withPeerConnection { setLocalDescription(stereoAnswer) }.nullSafe()) { - is Either.Left -> false - is Either.Right -> { - LKLog.e { "error setting local description for munged answer: ${outcome.value}" } - true - } - } - } - - if (shouldFallback) { - // Fall back to the un-munged answer rather than leaving the - // subscriber without a local description (mirrors - // PeerConnectionTransport.setMungedSdp). - when (val fallback = subscriber?.withPeerConnection { setLocalDescription(answer) }.nullSafe()) { - is Either.Left -> Unit - is Either.Right -> { - LKLog.e { "error setting local description for answer: ${fallback.value}" } - return@launch - } - } - client.sendAnswer(answer, offerId) - return@launch - } + val answerToSend = setLocalDescriptionWithStereoFallback( + stereoAnswer = stereoAnswer, + fallbackAnswer = answer, + ) ?: return@launch if (isClosed) { return@launch } - client.sendAnswer(stereoAnswer, offerId) + client.sendAnswer(answerToSend, offerId) } } + private suspend fun setLocalDescriptionWithStereoFallback( + stereoAnswer: SessionDescription, + fallbackAnswer: SessionDescription, + ): SessionDescription? { + val outcome = subscriber?.withPeerConnection { setLocalDescription(stereoAnswer) }.nullSafe() + if (outcome is Either.Left) { + return stereoAnswer + } + LKLog.e { "error setting local description for munged answer: ${outcome.value}" } + // Fall back to the un-munged answer rather than leaving the + // subscriber without a local description (mirrors + // PeerConnectionTransport.setMungedSdp). + val fallback = subscriber?.withPeerConnection { setLocalDescription(fallbackAnswer) }.nullSafe() + if (fallback is Either.Left) { + return fallbackAnswer + } + LKLog.e { "error setting local description for answer: ${fallback.value}" } + return null + } + override fun onTrickle(candidate: IceCandidate, target: LivekitRtc.SignalTarget) { LKLog.v { "received ice candidate from peer: $candidate, $target" } when (target) { From 4e07201dcae950b4ef7a2b74165a8c169f18c8c0 Mon Sep 17 00:00:00 2001 From: kirill-jjj Date: Tue, 8 Sep 2026 22:06:13 +0500 Subject: [PATCH 3/4] fix: use when-based type narrowing in stereo fallback helper MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Generic Either is not smart-cast from an if-check; when() narrows the Right branch for .value access. Generated with Codebuff 🤖 Co-Authored-By: Codebuff --- .../java/io/livekit/android/room/RTCEngine.kt | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/livekit-android-sdk/src/main/java/io/livekit/android/room/RTCEngine.kt b/livekit-android-sdk/src/main/java/io/livekit/android/room/RTCEngine.kt index 4c5180cdc..26f1e5351 100644 --- a/livekit-android-sdk/src/main/java/io/livekit/android/room/RTCEngine.kt +++ b/livekit-android-sdk/src/main/java/io/livekit/android/room/RTCEngine.kt @@ -1177,20 +1177,20 @@ internal constructor( stereoAnswer: SessionDescription, fallbackAnswer: SessionDescription, ): SessionDescription? { - val outcome = subscriber?.withPeerConnection { setLocalDescription(stereoAnswer) }.nullSafe() - if (outcome is Either.Left) { - return stereoAnswer + when (val outcome = subscriber?.withPeerConnection { setLocalDescription(stereoAnswer) }.nullSafe()) { + is Either.Left -> return stereoAnswer + is Either.Right -> LKLog.e { "error setting local description for munged answer: ${outcome.value}" } } - LKLog.e { "error setting local description for munged answer: ${outcome.value}" } // Fall back to the un-munged answer rather than leaving the // subscriber without a local description (mirrors // PeerConnectionTransport.setMungedSdp). - val fallback = subscriber?.withPeerConnection { setLocalDescription(fallbackAnswer) }.nullSafe() - if (fallback is Either.Left) { - return fallbackAnswer + when (val fallback = subscriber?.withPeerConnection { setLocalDescription(fallbackAnswer) }.nullSafe()) { + is Either.Left -> return fallbackAnswer + is Either.Right -> { + LKLog.e { "error setting local description for answer: ${fallback.value}" } + return null + } } - LKLog.e { "error setting local description for answer: ${fallback.value}" } - return null } override fun onTrickle(candidate: IceCandidate, target: LivekitRtc.SignalTarget) { From d787073e5e64b5891f4dac9224dcf6d8dd6dc1a4 Mon Sep 17 00:00:00 2001 From: kirill-jjj Date: Tue, 8 Sep 2026 23:03:35 +0500 Subject: [PATCH 4/4] fix: replace conflicting stereo fmtp param instead of appending MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit If the answer already carried stereo=0, appending stereo=1 left two conflicting values and the decoder could stay mono. Drop any existing stereo=N and append stereo=1. Generated with Codebuff 🤖 Co-Authored-By: Codebuff --- .../android/webrtc/StereoSdpMunging.kt | 18 +++++++-- .../android/webrtc/StereoSdpMungingTest.kt | 37 +++++++++++++++++++ 2 files changed, 52 insertions(+), 3 deletions(-) diff --git a/livekit-android-sdk/src/main/java/io/livekit/android/webrtc/StereoSdpMunging.kt b/livekit-android-sdk/src/main/java/io/livekit/android/webrtc/StereoSdpMunging.kt index 7ea6a2f32..bd62487ba 100644 --- a/livekit-android-sdk/src/main/java/io/livekit/android/webrtc/StereoSdpMunging.kt +++ b/livekit-android-sdk/src/main/java/io/livekit/android/webrtc/StereoSdpMunging.kt @@ -26,6 +26,7 @@ import livekit.org.webrtc.SessionDescription private const val OPUS_CODEC = "opus" private const val STEREO_FMTP_PARAM = "stereo=1" +private const val STEREO_PARAM_PREFIX = "stereo=" private const val SPROP_STEREO_FMTP_PARAM = "sprop-stereo=1" private const val MID_ATTRIBUTE = "mid" @@ -124,7 +125,9 @@ private fun findOpusPayloadType(mediaDesc: MediaDescription): Long? { /* The native Opus decoder requires both sides of the negotiation to carry stereo=1. The server only puts sprop-stereo=1 into its offer; the answer must -add the stereo=1 parameter itself or received packets are decoded as mono. +add the stereo=1 parameter itself or received packets are decoded as mono. An +existing conflicting value such as stereo=0 is replaced, since the decoder +honors the first stereo parameter and would otherwise stay mono. */ private fun ensureStereoFmtpParam(mediaDesc: MediaDescription, payloadType: Long) { var fmtpFound = false @@ -133,9 +136,18 @@ private fun ensureStereoFmtpParam(mediaDesc: MediaDescription, payloadType: Long continue } fmtpFound = true - if (!fmtp.config.split(";").any { it.trim() == STEREO_FMTP_PARAM }) { + val params = fmtp.config.split(";").map { it.trim() } + val hasStereo = params.any { it.equals(STEREO_FMTP_PARAM, ignoreCase = true) } + val hasConflictingStereo = params.any { + it.startsWith(STEREO_PARAM_PREFIX, ignoreCase = true) && + !it.equals(STEREO_FMTP_PARAM, ignoreCase = true) + } + if (!hasStereo || hasConflictingStereo) { try { - attribute.setValue("${fmtp.payload} ${fmtp.config};$STEREO_FMTP_PARAM") + val updated = params + .filterNot { it.startsWith(STEREO_PARAM_PREFIX, ignoreCase = true) } + .plus(STEREO_FMTP_PARAM) + attribute.setValue("${fmtp.payload} ${updated.joinToString(";")}") } catch (_: SdpException) { LKLog.w { "stereo munging: failed to update opus fmtp line" } } diff --git a/livekit-android-test/src/test/java/io/livekit/android/webrtc/StereoSdpMungingTest.kt b/livekit-android-test/src/test/java/io/livekit/android/webrtc/StereoSdpMungingTest.kt index 264fd6313..59bf1685b 100644 --- a/livekit-android-test/src/test/java/io/livekit/android/webrtc/StereoSdpMungingTest.kt +++ b/livekit-android-test/src/test/java/io/livekit/android/webrtc/StereoSdpMungingTest.kt @@ -49,6 +49,22 @@ class StereoSdpMungingTest { assertEquals(NO_STEREO_OFFER_DESCRIPTION, munged.description) } + @Test + fun ensureStereoOpusReplacesConflictingMonoParam() { + val answer = answerOf(ANSWER_WITH_MONO_STEREO_DESCRIPTION) + val offer = offerOf(STEREO_OFFER_DESCRIPTION) + + val munged = answer.ensureStereoOpus(offer) + + val audioMedia = audioMid(munged, "1") + val stereoParams = audioMedia.getFmtps() + .first { (_, fmtp) -> fmtp.payload == 111L } + .second.config.split(";") + .map { it.trim() } + .filter { it.startsWith("stereo=") } + assertEquals(listOf("stereo=1"), stereoParams) + } + @Test fun ensureStereoOpusDoesNotDuplicateStereoParam() { val answer = answerOf(ANSWER_WITH_STEREO_DESCRIPTION) @@ -122,6 +138,27 @@ class StereoSdpMungingTest { "a=fmtp:63 111/111\r\n" + "a=recvonly\r\n" + // The answer negotiated stereo=0: munging must replace it with stereo=1. + private const val ANSWER_WITH_MONO_STEREO_DESCRIPTION = "v=0\r\n" + + "o=- 3119613797835240840 4 IN IP4 127.0.0.1\r\n" + + "s=-\r\n" + + "t=0 0\r\n" + + "a=group:BUNDLE 0 1\r\n" + + "a=msid-semantic: WMS\r\n" + + "m=application 9 UDP/DTLS/SCTP webrtc-datachannel\r\n" + + "c=IN IP4 0.0.0.0\r\n" + + "a=mid:0\r\n" + + "a=sctp-port:5000\r\n" + + "m=audio 9 UDP/TLS/RTP/SAVPF 111 63\r\n" + + "c=IN IP4 0.0.0.0\r\n" + + "a=mid:1\r\n" + + "a=rtcp-mux\r\n" + + "a=rtpmap:111 opus/48000/2\r\n" + + "a=fmtp:111 minptime=10;useinbandfec=1;stereo=0\r\n" + + "a=rtpmap:63 red/48000/2\r\n" + + "a=fmtp:63 111/111\r\n" + + "a=recvonly\r\n" + // The answer already negotiated stereo=1: munging must not duplicate it. private const val ANSWER_WITH_STEREO_DESCRIPTION = "v=0\r\n" + "o=- 3119613797835240840 4 IN IP4 127.0.0.1\r\n" +