import Foundation /// What Musubi needs from a browser's SDP offer (Unified Plan, BUNDLE, rtcp-mux). struct SDPOffer: Equatable { struct Media: Equatable { var kind: String var mid: String var port: Int var payloadTypes: [Int] var rtpmap: [Int: String] = [:] var fmtp: [Int: String] = [:] } var iceUfrag: String var icePwd: String /// `sha-256` fingerprint, uppercase hex with colons. var fingerprint: String var media: [Media] enum ParseError: Error, Equatable { case missing(String) } init(_ sdp: String) throws { var sessionUfrag: String?, sessionPwd: String?, sessionFingerprint: String? var mediaUfrag: String?, mediaPwd: String?, mediaFingerprint: String? var media: [Media] = [] for raw in sdp.split(whereSeparator: \.isNewline) { let line = raw.trimmingCharacters(in: .whitespaces) if line.hasPrefix("m=") { let parts = line.dropFirst(2).split(separator: " ") guard parts.count >= 3 else { continue } media.append(Media(kind: String(parts[0]), mid: "\(media.count)", port: Int(parts[1]) ?? 0, payloadTypes: parts.dropFirst(3).compactMap { Int($0) })) continue } guard line.hasPrefix("a=") else { continue } let attribute = line.dropFirst(2) let (name, value) = attribute.firstIndex(of: ":").map { (String(attribute[..<$0]), String(attribute[attribute.index(after: $0)...])) } ?? (String(attribute), "") let inMedia = !media.isEmpty switch name { case "ice-ufrag": if inMedia { mediaUfrag = mediaUfrag ?? value } else { sessionUfrag = value } case "ice-pwd": if inMedia { mediaPwd = mediaPwd ?? value } else { sessionPwd = value } case "fingerprint": let parts = value.split(separator: " ") guard parts.count == 2, parts[0].lowercased() == "sha-256" else { continue } if inMedia { mediaFingerprint = mediaFingerprint ?? parts[1].uppercased() } else { sessionFingerprint = parts[1].uppercased() } case "mid" where inMedia: media[media.count - 1].mid = value case "rtpmap" where inMedia, "fmtp" where inMedia: guard let space = value.firstIndex(of: " "), let pt = Int(value[.. Int? { media.payloadTypes.first { media.rtpmap[$0]?.lowercased().hasPrefix("opus/48000") == true } } /// Payload type for H.264 with packetization-mode=1 in (constrained) baseline profile. func h264PayloadType(in media: Media) -> Int? { let candidates = media.payloadTypes.filter { pt in guard media.rtpmap[pt]?.uppercased().hasPrefix("H264/90000") == true, let fmtp = media.fmtp[pt]?.lowercased() else { return false } return fmtp.contains("packetization-mode=1") } func profile(_ pt: Int) -> String { media.fmtp[pt]?.lowercased().split(separator: ";") .first { $0.trimmingCharacters(in: .whitespaces).hasPrefix("profile-level-id=") } .map { String($0.split(separator: "=").last ?? "") } ?? "" } return candidates.first { profile($0).hasPrefix("42e0") } ?? candidates.first { profile($0).hasPrefix("4200") } } } /// Builds the ICE-lite, DTLS-passive, send-only answer. struct SDPAnswer { struct Track { var mid: String var kind: String /// nil rejects the section (port 0). var payloadType: Int? var ssrc: UInt32 } var iceUfrag: String var icePwd: String var fingerprint: String var address: String var port: UInt16 var tracks: [Track] var text: String { let accepted = tracks.filter { $0.payloadType != nil } var lines = [ "v=0", "o=- \(UInt32.random(in: 1...UInt32.max)) 2 IN IP4 \(address)", "s=Musubi", "t=0 0", "a=ice-lite", "a=group:BUNDLE \(accepted.map(\.mid).joined(separator: " "))", "a=msid-semantic: WMS musubi", ] for track in tracks { guard let pt = track.payloadType else { lines += ["m=\(track.kind) 0 UDP/TLS/RTP/SAVPF 0", "c=IN IP4 0.0.0.0", "a=mid:\(track.mid)", "a=inactive"] continue } lines += [ "m=\(track.kind) \(port) UDP/TLS/RTP/SAVPF \(pt)", "c=IN IP4 \(address)", "a=rtcp:\(port) IN IP4 \(address)", "a=ice-ufrag:\(iceUfrag)", "a=ice-pwd:\(icePwd)", "a=fingerprint:sha-256 \(fingerprint)", "a=setup:passive", "a=mid:\(track.mid)", "a=sendonly", "a=rtcp-mux", "a=msid:musubi \(track.kind)", ] if track.kind == "audio" { lines += ["a=rtpmap:\(pt) opus/48000/2", "a=fmtp:\(pt) minptime=10;useinbandfec=1"] } else { lines += [ "a=rtpmap:\(pt) H264/90000", "a=rtcp-fb:\(pt) nack pli", "a=rtcp-fb:\(pt) ccm fir", "a=fmtp:\(pt) level-asymmetry-allowed=1;packetization-mode=1;profile-level-id=42e01f", ] } lines += [ "a=ssrc:\(track.ssrc) cname:musubi", "a=ssrc:\(track.ssrc) msid:musubi \(track.kind)", "a=candidate:1 1 udp 2130706431 \(address) \(port) typ host", "a=end-of-candidates", ] } return lines.joined(separator: "\r\n") + "\r\n" } }