You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
155 lines
6.4 KiB
155 lines
6.4 KiB
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[..<space]) else { continue }
|
|
let rest = String(value[value.index(after: space)...])
|
|
if name == "rtpmap" { media[media.count - 1].rtpmap[pt] = rest } else { media[media.count - 1].fmtp[pt] = rest }
|
|
default: break
|
|
}
|
|
}
|
|
|
|
guard let ufrag = sessionUfrag ?? mediaUfrag else { throw ParseError.missing("ice-ufrag") }
|
|
guard let pwd = sessionPwd ?? mediaPwd else { throw ParseError.missing("ice-pwd") }
|
|
guard let fingerprint = sessionFingerprint ?? mediaFingerprint else { throw ParseError.missing("fingerprint") }
|
|
iceUfrag = ufrag
|
|
icePwd = pwd
|
|
self.fingerprint = fingerprint
|
|
self.media = media
|
|
}
|
|
|
|
/// Payload type for Opus at 48 kHz.
|
|
func opusPayloadType(in media: Media) -> 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"
|
|
}
|
|
}
|