WebRTCKit is a repository that simplifies WebRTC for the use in an iOS app. It is specialized for simple peer-to-peer video calls between two peers.
- Create calls between two peers that support video & audio.
- Add data channels.
- Use your own signaling server using our delegate.
- Automatic Bitrate adjustment based on network conditions.
- Completely compatible with Swift 6 and it's enforced thread safety.
You can use Swift Package Manager to integrate WebRTCKit into your app using this URL:
https://github.com/resignedScientist/WebRTCKit.git
First, you need to set the delegate and setup the connection.
// Set the delegate.
await webRTCController.setCallManagerDelegate(self)
// Setup the connection, connecting and registering to the signaling server.
let localPeerID = try await webRTCController.setupConnection()
Start the recording of audio & video like this:
try await webRTCController.startRecording()
To start a call, you can call the controller providing the ID of your peer:
try await webRTCController.sendCallRequest(to: peerID)
The other peer receives the call in the CallManagerDelegate
and can answer the call:
try await webRTCController.answerCallRequest(accept: true)
…or reject it:
try await webRTCController.answerCallRequest(accept: false)
Finally, this is how you end the call:
try await webRTCController.endCall()
…or close the connection with the signaling server as well:
try await webRTCController.disconnect()
To more efficiently make configuration changes while calling
and only send one updated negotiation offer to our peer,
you can make use of startConfiguration
and commitConfiguration
.
For opening data channels, this is necessary.
// start the configuration which prevents sending negotiation offers
try await webRTCController.startConfiguration()
// make changes here like starting video recording or opening data channels
// commit the configuration which sends one negotiation offer with all changes
try await webRTCController.commitConfiguration()
The call starts with only audio. To enable video, you should call:
try await webRTCController.startVideoRecording(videoCapturer: videoCapturer)
You can do this before starting the call or in the middle of the call.
There is also support for data channels. This is only possible after the callDidStart
function
of the CallManagerDelegate
was called.
You need to call startConfiguration
before adding them and commitConfiguration
after adding them for them to be opened.
Here is an example on how to open them:
func openDataChannels() async throws {
try await webRTCController.startConfiguration()
// config with disabled retransmission of failed values
let noRetransmitsConfig = RTCDataChannelConfiguration()
noRetransmitsConfig.maxRetransmits = 0
// first channel
self.firstChannel = try await webRTCController.createDataChannel(
label: "firstChannel"
)
await firstChannel?.setDelegate(self)
// second channel
self.secondChannel = try await webRTCController.createDataChannel(
label: "secondChannel",
config: noRetransmitsConfig
)
await secondChannel?.setDelegate(self)
try await webRTCController.commitConfiguration()
}
As delegate we use the RTCDataChannelDelegate
protocol provided by WebRTC.
extension MyClass: RTCDataChannelDelegate {
func dataChannelDidChangeState(_ dataChannel: RTCDataChannel) {
}
func dataChannel(_ dataChannel: RTCDataChannel, didReceiveMessageWith buffer: RTCDataBuffer) {
}
}
You can setup WebRTCKit like this. Run this when your application starts and keep a reference to the WebRTCController
.
There are two mendatory parameters signalingServer
and config
that I will explain below.
let signalingServer = await SignalingServerConnectionImpl()
let webRTCController = await WebRTCKit.initialize(
signalingServer: signalingServer,
config: config
)
I recommend to put the config into a JSON file like this:
{
"iceServers": [
{
"urlStrings": [
"stun:example.com:1234"
]
},
{
"urlStrings": [
"turn:example.com:1234"
],
"username": "username",
"credential": "credential"
}
],
"connectionTimeout": 30,
"video": {
"minBitrate": 100000,
"maxBitrate": 6000000,
"startBitrate": 1000000,
"bitrateStepUp": 0.15,
"bitrateStepDown": 0.15,
"bitrateStepCriticalDown": 0.50,
"criticalPacketLossThreshold": 0.10,
"highPacketLossThreshold": 0.05,
"lowPacketLossThreshold": 0.01
},
"audio": {
"minBitrate": 6000,
"maxBitrate": 96000,
"startBitrate": 16000,
"bitrateStepUp": 0.15,
"bitrateStepDown": 0.15,
"bitrateStepCriticalDown": 0.50,
"criticalPacketLossThreshold": 0.10,
"highPacketLossThreshold": 0.05,
"lowPacketLossThreshold": 0.01
}
}
Here a short explanation of each parameter:
/// The ICE-Servers to use for connection establishment.
public let iceServers: [RTCIceServer]
/// The number of seconds we can be in the connecting state before aborting the call.
public let connectionTimeout: UInt64
/// The bitrate configuration for video data.
public let video: BitrateConfig
/// The bitrate configuration for audio data.
public let audio: BitrateConfig
There is an automatic Bitrate adjustment integrated that you can configure here.
The Bitrate adjustment works as following:
- If the packet loss of the last second is >=
criticalPacketLossThreshold
, the bitrate is dropped bybitrateStepCriticalDown
. - Every 5 seconds we check the packet loss of the last 10 seconds.
- If it is >=
highPacketLossThreshold
, we decrease the bitrate bybitrateStepDown
. - If it is <
lowPacketLossThreshold
, we increase the bitrate bybitrateStepUp
.
- If it is >=
This automatically adjusts bitrates to changing network conditions and keeps the connection stable.
/// The bitrate does not go below this value.
let minBitrate: Int
/// The bitrate does not go above this value.
let maxBitrate: Int
/// The initial bitrate to try when the call starts.
let startBitrate: Int
/// If network conditions are good, step up this percentage (value between 0-1).
let bitrateStepUp: Double
/// If network conditions are bad, step down this percentage (value between 0-1).
let bitrateStepDown: Double
/// If network conditions are critical, step down this percentage (value between 0-1).
let bitrateStepCriticalDown: Double
/// The percentage threshold when packet loss counts as critical (value between 0-1).
let criticalPacketLossThreshold: Double
/// The percentage threshold when packet loss counts as high (value between 0-1).
let highPacketLossThreshold: Double
/// The percentage threshold under which packet loss counts as low (value between 0-1).
let lowPacketLossThreshold: Double
There are some default values for audio & video bitrates as well that worked for me:
public static var defaultForVideo: BitrateConfig {
BitrateConfig(
minBitrate: 100_000,
maxBitrate: 6_000_000,
startBitrate: 1_000_000,
bitrateStepUp: 0.15,
bitrateStepDown: 0.15,
bitrateStepCriticalDown: 0.25,
criticalPacketLossThreshold: 0.10,
highPacketLossThreshold: 0.05,
lowPacketLossThreshold: 0.01
)
}
public static var defaultForAudio: BitrateConfig {
BitrateConfig(
minBitrate: 6_000,
maxBitrate: 96_000,
startBitrate: 16_000,
bitrateStepUp: 0.15,
bitrateStepDown: 0.15,
bitrateStepCriticalDown: 0.25,
criticalPacketLossThreshold: 0.10,
highPacketLossThreshold: 0.05,
lowPacketLossThreshold: 0.01
)
}
To work around NATs, WebRTC uses STUN
and TURN
servers to establish a peer-to-peer connection. It is highly recommended to provide both of them in the config.
There are some servers you can rent for that, or you can use something like Coturn
to host it on your own.
Just like in WebRTC itself, you have to provide your own signaling server that helps to establish a peer-to-peer connection between two devices.
For this reason the SignalingServerConnection
protocol exists. You need to implement this and send messages through your server to the other peer.
I recommend using Starscream
to connect to your WebSocket server.
public protocol SignalingServerConnection: Sendable {
/// Returns true if the connection is established / open.
var isOpen: Bool { get }
/// Set the delegate.
func setDelegate(_ delegate: SignalingServerDelegate?)
/// Establish a connection to the signaling server and receive the peer id.
func connect() async throws -> PeerID
/// Disconnect from the signaling server.
func disconnect()
/// Send a signal to the other peer through the signaling server.
/// - Parameters:
/// - signal: The signal to send as data.
/// - destinationID: The ID of the other peer.
func sendSignal(_ signal: Data, to destinationID: PeerID) async throws
/// Send an ICE candidate to the other peer through the signaling server.
/// - Parameters:
/// - candidate: The ICE candidate as data.
/// - destinationID: The ID of the other peer.
func sendICECandidate(_ candidate: Data, to destinationID: PeerID) async throws
/// Notify the other peer that we end the call.
/// - Parameter destinationID: The ID of the other peer.
func sendEndCall(to destinationID: PeerID) async throws
/// The network connection has been re-established.
func onConnectionSatisfied()
/// The network connection was lost.
func onConnectionUnsatisfied()
}
The CallManagerDelegate
looks like this. Most of the times the delegate will be something like your view model.
You will receive data channels only when the other peer is opening the channel. If your own peer opens a channel, you should keep your own reference.
public protocol CallManagerDelegate: AnyObject, Sendable {
/// We received an incoming call.
/// - Parameter peerID: The ID of the peer which is calling.
func didReceiveIncomingCall(from peerID: PeerID)
/// We did add a local video track to the stream.
///
/// The view can show the local video using the `WebRTCVideoView`.
/// - Parameter videoTrack: The local video track.
func didAddLocalVideoTrack(_ videoTrack: WRKRTCVideoTrack)
/// Our remote peer did add a video track to the stream.
///
/// The view can show the remote video using the `WebRTCVideoView`.
/// - Parameter videoTrack: The remote video track.
func didAddRemoteVideoTrack(_ videoTrack: WRKRTCVideoTrack)
/// We did add a local audio track to the session.
///
/// It will be sent automatically. You can use this track instance to mute it for example.
/// - Parameter audioTrack: The local audio track.
func didAddLocalAudioTrack(_ audioTrack: WRKRTCAudioTrack)
/// Our remote peer did add an audio track to the session.
///
/// It will be played automatically. You can use this track instance to mute it for example.
/// - Parameter audioTrack: The remote audio track.
func didAddRemoteAudioTrack(_ audioTrack: WRKRTCAudioTrack)
/// Tells the delegate that the remote video track has been removed.
/// - Parameter videoTrack: The remote video track.
func remoteVideoTrackWasRemoved(_ videoTrack: WRKRTCVideoTrack)
/// The call did start.
func callDidStart()
/// The call did end.
/// - Parameter error: Error if the call ended with an error.
func callDidEnd(withError error: CallManagerError?)
/// Called when the peer created a new data channel.
/// - Parameter dataChannel: The new data channel.
func didReceiveDataChannel(_ dataChannel: WRKDataChannel)
}
In these delegate methods, you will receive a reference to the local and remote audio tracks:
func didAddLocalAudioTrack(_ audioTrack: WRKRTCAudioTrack)
func didAddRemoteAudioTrack(_ audioTrack: WRKRTCAudioTrack)
You can mute the audio of each track by setting the isEnabled
property.
// mute audio
audioTrack.isEnabled = false
// unmute audio
audioTrack.isEnabled = true
Video tracks contain the local or remote video stream.
When receiving the video tracks, you can show them using the WebRTCVideoView
. You pass an optional video track here. If nil is passed, just a black view will be visible.
WebRTCVideoView(videoTrack: viewModel.localVideoTrack)
.background(Color.black)
.cornerRadius(15)
.frame(maxHeight: 230)
Custom video capturers allow you to feed frames from external sources (e.g., screen capture or augmented reality content) into WebRTC streams. Another use case is when you directly need access to the pixel buffers.
When starting recording, you can provide a custom video capturer. This conforms to RTCVideoCapturer
.
try await webRTCController.startRecording(videoCapturer: videoCapturer)
Here is an example implementation:
final class PixelBufferVideoCapturer: RTCVideoCapturer {
private let context = CIContext()
func captureFrame(_ pixelBuffer: CVPixelBuffer, imageOrientation: CGImagePropertyOrientation) {
let currentMediaTime = CACurrentMediaTime()
let time = CMTime(seconds: currentMediaTime, preferredTimescale: 1_000_000)
let seconds = CMTimeGetSeconds(time)
let timeStampNs = Int64(seconds * Double(NSEC_PER_SEC))
let buffer = RTCCVPixelBuffer(pixelBuffer: pixelBuffer)
let videoFrame = RTCVideoFrame(
buffer: buffer,
rotation: {
switch imageOrientation {
case .up, .upMirrored:
return ._0
case .right, .rightMirrored:
return ._90
case .down, .downMirrored:
return ._180
case .left, .leftMirrored:
return ._270
}
}(),
timeStampNs: timeStampNs
)
self.delegate?.capturer(self, didCapture: videoFrame)
}
}
You can also customize your audio source. Custom audio devices allow you to provide audio from different sources. Maybe you want to edit the audio before sending it or you want direct access to the audio buffers to use it e.g. for speech recognition while calling.
You can create your custom audio device by conforming to RTCAudioDevice
.
You pass it when initializing the framework:
WebRTCKit.initialize(
signalingServer: signalingServer,
config: config,
audioDevice: audioDevice // pass your custom audio device here
)
I recommend using AVAudioSinkNode
for audio input and AVAudioSourceNode
for playback.