Skip to content

Recording

TrackRecorder records one video track and one audio track into an MP4 file on the device. It does not belong to a room or a participant: you create it, give it the tracks, and keep it alive for as long as it records.

Call TrackRecorder on the main thread. objectWillChange and its three published properties, TrackRecorder.diskSpaceWarning, TrackRecorder.lastError and TrackRecorder.lastFinishedFile, are always sent on the main thread, so you can drive SwiftUI views from them directly.

Create a recorder and choose its tracks

Create a recorder with TrackRecorder.init(videoTrack:audioTrack:videoFps:videoSize:). Give it at least one track:

You pass The file has
A video track and an audio track Video with sound
Only a video track Video without sound
Only an audio track Sound only

The recorder takes one video track and one audio track. To record several sources, such as a whole meeting, combine them first and record the result: mix the audio with AudioCompositor and compose the video with VideoCompositor, then pass their AudioCompositor.outputTrack and VideoCompositor.outputTrack. See Compositing.

videoFps (default 20) and videoSize (default 1280×720) set the video in the file. Frames of a different size are fitted inside it, with black bars, not stretched.

Several recorders. You can create several recorders at the same time, each recording its own file. One track can feed several recorders and VideoViews at once.

Format. The file is always fragmented MP4 (fMP4) with H.264 video and AAC audio.

Start, pause, resume and stop

File name and location. Pass the file name without an extension: the SDK adds .mp4. Passing "meeting.mp4" saves meeting.mp4.mp4. Files are always saved in Documents/Recordings/ in the App's container (TrackRecorder.recordingsDirectory); you cannot choose another place. If a file with the same name already exists there, TrackRecorder.startRecording(fileName:) fails.

import RevueSDK

import Foundation

/// Record your own camera and microphone. Pass the file name without an extension: the SDK adds .mp4
/// and saves it in Documents/Recordings/. Keep the recorder alive for as long as it records.
@MainActor
func startSelfRecording(fileName: String) throws -> TrackRecorder {
    let me = Room.shared.localParticipant!
    let recorder = TrackRecorder(
        videoTrack: me.firstCameraVideoTrack,
        audioTrack: me.firstAudioTrack
    )
    try recorder.startRecording(fileName: fileName)
    return recorder
}

/// What to tell the user when startRecording throws.
func recordingStartFailureMessage(_ error: Error) -> String {
    if case RecordingError.insufficientDiskSpace = error {
        return "Not enough free space to record."
    }
    let error = error as NSError
    guard error.domain == "RecordingEngine" else { return error.localizedDescription }
    switch error.code {
    case -1: return "This recorder is already recording, or still finishing the last file."
    case -2: return "A recording with this name already exists."
    default: return "The recording could not start."  // -3: the file writer did not start
    }
}

Pause. The paused stretch is left out of the file, not recorded as black or silence. The file's length counts only the time actually recorded, so a 10-minute recording with a 3-minute pause gives a 7-minute file.

State and the observable properties

TrackRecorder.state is a RecorderState; TrackRecorder.isRecording is true while it is RecorderState.recording.

State Meaning
RecorderState.idle Not recording. You can start.
RecorderState.recording Recording.
RecorderState.paused Paused. Nothing is written until you resume.
RecorderState.finalizing Stopped; the file is being finished. You cannot start yet.

TrackRecorder is an ObservableObject. Besides state, it publishes:

Property What it holds
TrackRecorder.diskSpaceWarning The latest disk space warning for this recording, or nil. See Disk space.
TrackRecorder.lastError An error that happened during this recording, or nil. See Errors.
TrackRecorder.lastFinishedFile The most recent file this recorder finished. See Stop and get the file.
import RevueSDK

import Combine

/// Follow one recorder: disk space, errors while recording, and every finished file. All three are
/// published on the main thread, and startRecording clears diskSpaceWarning and lastError, so they always
/// describe the current recording.
final class RecorderObserver {
    var onLowDiskSpace: ((Int64) -> Void)?
    var onStopped: ((String) -> Void)?
    var onFileFinished: ((RecordingFileInfo) -> Void)?
    private var cancellables = Set<AnyCancellable>()

    init(recorder: TrackRecorder) {
        recorder.$diskSpaceWarning
            .compactMap { $0 }
            .sink { [weak self] warning in
                switch warning {
                case .warning(let available, _):
                    // Still recording: ask the user to free some space.
                    self?.onLowDiskSpace?(available)
                case .criticalStopped:
                    // The recorder has already stopped; the file arrives through lastFinishedFile.
                    self?.onStopped?("Recording stopped: the device is almost full.")
                }
            }
            .store(in: &cancellables)

        recorder.$lastError
            .compactMap { $0 }
            .sink { [weak self] error in
                // The recorder stops by itself after an error.
                self?.onStopped?("Recording stopped: \(error.localizedDescription)")
            }
            .store(in: &cancellables)

        // Set by all three ways a recording stops, including the two you have no completion for.
        recorder.$lastFinishedFile
            .compactMap { $0 }
            .sink { [weak self] info in self?.onFileFinished?(info) }
            .store(in: &cancellables)
    }
}

Stop and get the file

TrackRecorder.stopRecording(completion:) returns at once. The file is finished in the background; meanwhile state is RecorderState.finalizing, and it goes back to RecorderState.idle when the file is done.

import RevueSDK

/// stopRecording returns at once; the file is finished in the background while state is .finalizing.
/// Read the file only in the completion, which runs on the main thread. nil means nothing was written.
@MainActor
func finishRecording(_ recorder: TrackRecorder, onFile: @escaping (RecordingFileInfo?) -> Void) {
    recorder.stopRecording { info in
        guard let info else {
            onFile(nil)
            return
        }
        // info.duration counts only the recorded time: paused stretches are not in the file.
        onFile(info)
    }
}

Disk space

TrackRecorder.startRecording(fileName:) checks the free space first. While recording, the recorder checks it every 5 seconds against two thresholds:

Free space falls below TrackRecorder.diskSpaceWarning becomes The recording
TrackRecorder.diskSpaceWarningThreshold (default 100 MB) DiskSpaceWarning.warning(available:threshold:) Goes on
TrackRecorder.diskSpaceCriticalThreshold (default 50 MB) DiskSpaceWarning.criticalStopped(available:threshold:) Stops by itself; the file arrives through TrackRecorder.lastFinishedFile

Both thresholds are global settings, shared by every recorder: changing one changes it for all recorders. TrackRecorder.availableDiskSpace() returns the free space in bytes.

Errors

Starting. TrackRecorder.startRecording(fileName:) throws two kinds of error:

Error Why
RecordingError.insufficientDiskSpace(available:required:) Free space is below TrackRecorder.diskSpaceCriticalThreshold.
NSError, domain "RecordingEngine", code -1 This recorder is already recording, or still finishing the previous file.
NSError, domain "RecordingEngine", code -2 A file with this name already exists.
NSError, domain "RecordingEngine", code -3 The file writer could not start.

When the system reports why the file writer could not start, you receive the system's error instead of code -3.

While recording. When writing fails during a recording, the recorder sets TrackRecorder.lastError and stops by itself, keeping what was already written.

TrackRecorder.lastError and TrackRecorder.diskSpaceWarning describe only the current recording. A successful TrackRecorder.startRecording(fileName:) resets both to nil, so a warning or error from the previous recording does not carry over.

Manage recorded files

These are static methods on TrackRecorder, and work on the files in Documents/Recordings/:

Method Does
TrackRecorder.getRecordingList() Returns every recording as a RecordingFileInfo, newest first.
TrackRecorder.deleteRecording(fileId:) Deletes the recording with this ID.
TrackRecorder.deleteRecording(at:) Deletes the file at this URL.
TrackRecorder.deleteAllRecordings() Deletes every recording.

RecordingFileInfo.id is the file name without the extension: the recording meeting.mp4 has the ID meeting. TrackRecorder.deleteRecording(fileId:) uses it to find the file.

import RevueSDK

/// List the recordings in Documents/Recordings/, newest first.
func savedRecordings() -> [RecordingFileInfo] {
    TrackRecorder.getRecordingList()
}

/// Delete one recording. RecordingFileInfo.id is the file name without the extension, which is what
/// deleteRecording(fileId:) looks for.
func deleteSavedRecording(_ info: RecordingFileInfo) throws {
    try TrackRecorder.deleteRecording(fileId: info.id)
}

Files that were not finished

While recording, the file is written in fragments, one every second. If the App crashes, or the recorder is released without TrackRecorder.stopRecording(completion:), the file keeps everything up to the last fragment that was written. To get a complete file, call TrackRecorder.stopRecording(completion:) and wait for the completion.