Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -770,7 +770,27 @@ class RTCPeerConnectionCoordinator: @unchecked Sendable {
)
)

let answer = try await createAnswer()
var answer = try await createAnswer()

// Enable Stereo - Begin
let sdpParser = SDPParser()
let stereoEnableVisitor = StereoEnableVisitor()
sdpParser.registerVisitor(stereoEnableVisitor)
await sdpParser.parse(sdp: offerSdp)

if !stereoEnableVisitor.found.isEmpty {
let sdpWriter = SDPWriter()
let stereoEnableWriter = StereoEnableWriter(stereoEnableVisitor.found)
sdpWriter.registerWriter(stereoEnableWriter)
let modifiedAnswerSDP = await sdpWriter.write(sdp: answer.sdp)
answer = .init(type: answer.type, sdp: modifiedAnswerSDP)
log.debug(
"Answer SDP has been modifier with enabled stereo for mid values:\(stereoEnableVisitor.found.keys.joined(separator: ","))",
subsystems: .webRTC
)
}
// Enable Stereo - End

try await setLocalDescription(answer)

try await sfuAdapter.sendAnswer(
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
//
// Copyright © 2025 Stream.io Inc. All rights reserved.
//

import Foundation

struct MidStereoInformation {
var mid: String
var codecPayload: String
var isStereoEnabled: Bool
}
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,12 @@ enum SupportedPrefix: String, Hashable, CaseIterable {
/// Represents the `a=rtpmap:` prefix, commonly used in SDP to describe RTP payload formats.
case rtmap = "a=rtpmap:"

case media = "m="

case mid = "a=mid:"

case fmtp = "a=fmtp:"

/// Determines if a line contains a supported prefix.
///
/// - Parameter line: A `String` representing an SDP line.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ final class SDPParser {
func registerVisitor(_ visitor: SDPLineVisitor) {
visitors.append(visitor)
}

/// Parses the provided SDP string asynchronously.
/// - Parameter sdp: The SDP string to parse.
func parse(sdp: String) async {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
//
// Copyright © 2025 Stream.io Inc. All rights reserved.
//

import Foundation

/// A visitor that enables stereo in the answer SDP based on stereo being offered.
final class StereoEnableVisitor: SDPLineVisitor {

enum State {
case idle
case foundMedia(line: String)
case foundMid(mid: String)
case foundOpus(mid: String, payload: String)
}

private var state: State = .idle
private(set) var found: [String: MidStereoInformation] = [:]

/// Prefixes handled by this visitor: mid, rtpmap, and fmtp lines.
var supportedPrefixes: Set<SupportedPrefix> {
[.media, .mid, .rtmap, .fmtp]
}

init() {}

func visit(line: String) {
switch (line, state) {
case (_, .idle) where line.hasPrefix(SupportedPrefix.media.rawValue) && line.contains("audio") == true:
state = .foundMedia(line: line)
case (_, .foundMedia) where line.hasPrefix(SupportedPrefix.mid.rawValue):
state = .foundMid(
mid: line.replacingOccurrences(of: SupportedPrefix.mid.rawValue, with: "")
)

case let (_, .foundMid(mid)) where line.hasPrefix(SupportedPrefix.rtmap.rawValue):
let parts = line.replacingOccurrences(of: SupportedPrefix.rtmap.rawValue, with: "")
.split(separator: " ", maxSplits: 1)
guard parts.endIndex == 2, parts[1].lowercased().contains("opus") else {
state = .idle
return
}
state = .foundOpus(mid: mid, payload: String(parts[0]))

case let (_, .foundOpus(mid, codecPayload)) where line.hasPrefix(SupportedPrefix.fmtp.rawValue):
let parts = line
.replacingOccurrences(of: SupportedPrefix.fmtp.rawValue, with: "")
.split(separator: " ", maxSplits: 1)

guard parts.endIndex == 2 else {
state = .idle
return
}

let payload = String(parts[0])
let config = String(parts[1])

guard
payload == codecPayload,
config.contains("stereo=1")
else {
state = .idle
return
}

found[mid] = .init(
mid: mid,
codecPayload: codecPayload,
isStereoEnabled: true
)
state = .idle

default:
break
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
//
// Copyright © 2025 Stream.io Inc. All rights reserved.
//

import Foundation

/// The main SDP parser that uses visitors to process lines.
final class SDPWriter {
private var writers: [SDPLineWriter] = []

/// Registers a visitor for a specific SDP line prefix.
/// - Parameters:
/// - prefix: The line prefix to handle (e.g., "a=rtpmap").
/// - visitor: The visitor that processes lines with the specified prefix.
func registerWriter(_ writer: SDPLineWriter) {
writers.append(writer)
}

func write(sdp: String) async -> String {
let lines = sdp.split(separator: "\r\n")
var updatedLines = [String]()
for line in lines {
let line = String(line)
let supportedPrefix = SupportedPrefix.isPrefixSupported(for: line)
guard
supportedPrefix != .unsupported
else {
updatedLines.append(line)
continue
}

let updatedLine = writers.reduce(line) { partialResult, writer in
writer.visit(line: partialResult)
}

updatedLines.append(updatedLine)
}

return updatedLines.joined(separator: "\r\n")
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
//
// Copyright © 2025 Stream.io Inc. All rights reserved.
//

import Foundation

protocol SDPLineWriter {

var supportedPrefixes: Set<SupportedPrefix> { get }

func visit(line: String) -> String
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
//
// Copyright © 2025 Stream.io Inc. All rights reserved.
//

import Foundation

final class StereoEnableWriter: SDPLineWriter {

enum State {
case idle
case foundMedia(line: String)
case foundMid(mid: String)
case foundOpus(mid: String, payload: String)
}

private var state: State = .idle
private let data: [String: MidStereoInformation]

/// Prefixes handled by this visitor: mid, rtpmap, and fmtp lines.
var supportedPrefixes: Set<SupportedPrefix> {
[.media, .mid, .rtmap, .fmtp]
}

init(_ data: [String: MidStereoInformation]) {
self.data = data
}

func visit(line: String) -> String {
switch (line, state) {
case (_, .idle) where line.hasPrefix(SupportedPrefix.media.rawValue) && line.contains("audio") == true:
state = .foundMedia(line: line)
return line
case (_, .foundMedia) where line.hasPrefix(SupportedPrefix.mid.rawValue):
state = .foundMid(
mid: line.replacingOccurrences(of: SupportedPrefix.mid.rawValue, with: "")
)
return line

case let (_, .foundMid(mid)) where line.hasPrefix(SupportedPrefix.rtmap.rawValue):
let parts = line.replacingOccurrences(of: SupportedPrefix.rtmap.rawValue, with: "")
.split(separator: " ", maxSplits: 1)
guard parts.endIndex == 2, parts[1].lowercased().contains("opus") else {
state = .idle
return line
}
state = .foundOpus(mid: mid, payload: String(parts[0]))
return line

case let (_, .foundOpus(mid, codecPayload)) where line.hasPrefix(SupportedPrefix.fmtp.rawValue):
guard
let entry = data[mid],
entry.codecPayload == codecPayload,
line.contains("stereo=1") == false
else {
state = .idle
return line
}

state = .idle
return line + ";stereo=1"

default:
return line
}
}
}
Loading