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¶
TrackRecorder.startRecording(fileName:)starts a recording.TrackRecorder.pauseRecording()pauses it, andTrackRecorder.resumeRecording()continues it.TrackRecorder.stopRecording(completion:)stops it and finishes the file.
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.
- Read the file only after the completion runs. Before that the file is not complete. The completion runs on
the main thread with the file's
RecordingFileInfo, or with nil when nothing was recording or the file could not be written. - A recording stops in three ways: you call
TrackRecorder.stopRecording(completion:), an error stops it, or low disk space stops it. All three updateTrackRecorder.lastFinishedFile. Only the first has a completion, so to learn about the other two, watchTrackRecorder.lastFinishedFile. When the file could not be written, it keeps its previous value.
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.