Forking LiveKit React Native for local audio capture
Stream-and-store: recording local backups from the same WebRTC pipeline
A working real-time audio stream is not the same as a durable audio file. We learned this the wrong way: a network hiccup could take the whole session. The audio was gone. The user had to start over.
The obvious fix was a local backup recording running alongside the live stream. But iOS and Android will not share an active microphone between two capture sessions. Open a second recorder while LiveKit owns the mic and you get silence on one channel or an OS-level conflict that kills both. The only clean path was to tap the samples LiveKit already had before they left the device, and write them to a file from inside the same pipeline.
The upstream React Native SDK had no API for that. We forked.
Two failure modes, one microphone
LiveKit’s WebRTC path handles real-time delivery. A dropped packet is acceptable; a broken connection ends the stream. Local file recording handles durability. A crash mid-session leaves a partial file; a clean stop leaves a complete one. Neither path covers the other’s failure mode.
Starting a separate MediaRecorder or AudioRecord alongside LiveKit looks simple on paper. In practice the platform audio session treats the two recorders as competing capture clients. On Android, the second AudioRecord opens but delivers zeros. On iOS, AVAudioSession grants exclusive access to whichever recorder acquires it first, and the second gets silence or an error. Even when both open without crashing, the echo cancellation and noise suppression chains diverge because they run on different input streams. The resulting files are not equivalent.
The samples we needed were already processed and available inside the LiveKit pipeline. We needed a way to intercept them.
The tee
LiveKit’s Android SDK dispatches decoded audio frames to registered AudioTrackSink implementations. The iOS SDK calls registered RTCAudioRenderer instances with AVAudioPCMBuffer callbacks. Both are internal APIs the React Native wrapper did not expose.
flowchart LR
M[Microphone] --> P[LiveKit / WebRTC audio pipeline]
P --> N[Encoded network stream]
P --> S[Native PCM sink]
S --> F[Local durable file]
N --> T[Real-time transcription]
The fork added a FileAudioSink on each platform, registered against the same dispatcher that feeds the encoder. The microphone opens once. The same processed frames go to the network and to disk.
Android: registering the sink
LiveKit’s Android SDK exposes an AudioSink interface with a single callback:
interface AudioSink {
fun onAudioSampleReceived(
audioFormat: AudioFormat,
channelCount: Int,
sampleCount: Int,
sampleData: ByteArray
)
}
The LocalAudioTrack holds a reference to the audio processing module that dispatches these frames. The fork added a FileAudioSink that opens a FileOutputStream on startRecording, appends raw PCM bytes in the callback, and flushes and closes on stopRecording. The sink registers itself with the dispatcher on start and unregisters before the track is released.
class FileAudioSink(private val filePath: String) : AudioSink {
private var outputStream: FileOutputStream? = null
fun startRecording() {
outputStream = FileOutputStream(filePath)
}
override fun onAudioSampleReceived(
audioFormat: AudioFormat,
channelCount: Int,
sampleCount: Int,
sampleData: ByteArray
) {
outputStream?.write(sampleData)
}
fun stopRecording() {
outputStream?.flush()
outputStream?.close()
outputStream = null
}
}
The callback runs on a LiveKit audio thread. Writing synchronously here is safe for typical session lengths; the frames are small and the OS page cache absorbs bursts. For very long sessions, a bounded queue and a background writer thread reduce jitter.
iOS: adding an audio renderer
On iOS, LKLocalAudioTrack uses RTCAudioDevice’s processing manager to push audio. The SDK already supported in-memory consumers via AudioRenderer. The fork implemented RTCAudioRenderer with a file sink:
class LKFileAudioSink: NSObject, AudioRenderer {
private var fileHandle: FileHandle?
private var locked = false
private var format: AVAudioFormat?
func startRecording(path: String) throws {
FileManager.default.createFile(atPath: path, contents: nil)
fileHandle = FileHandle(forWritingAtPath: path)
locked = false
}
func render(pcmBuffer: AVAudioPCMBuffer) {
guard let handle = fileHandle else { return }
if !locked {
format = pcmBuffer.format
locked = true
}
guard let data = pcmBuffer.toData() else { return }
handle.write(data)
}
func stopRecording() -> AVAudioFormat? {
fileHandle?.closeFile()
fileHandle = nil
return format
}
}
The format is locked on the first frame. Subsequent frames that arrive with a different format are dropped. This keeps the file valid as a single-format PCM stream, which matters when the caller needs to compute duration from file size.
Duration is not stored in a raw PCM file. The application derives it: file_size / (sample_rate * channel_count * bytes_per_sample). The recorded format object from stopRecording supplies those parameters.
The React Native bridge
The fork exposed three methods from a native module:
AudioRecorderModule.configure({ sampleRate: 16000, channelCount: 1 });
AudioRecorderModule.startRecording(filePath);
const { path, durationMs } = await AudioRecorderModule.stopRecording();
configure sets the expected format so both platforms lock to a consistent layout. startRecording registers the sink and begins writing. stopRecording flushes, unregisters, and returns the file path alongside a derived duration.
flowchart TB
JS[React Native AudioRecorderModule]
JS --> A[Android Kotlin module]
JS --> I[iOS Swift module]
A --> AS[AudioTrackSink]
I --> IS[RTCAudioRenderer]
AS --> PF[PCM file]
IS --> PF
Application code validates the segment after stop: check that the file exists, that its size is nonzero, and that the computed duration is within a plausible range for the session. A file that is too short indicates a crash or a missed start call.
What the upstream SDK had and what it lacked
| Capability | Upstream client | Fork |
|---|---|---|
| Publish microphone audio over WebRTC | Yes | Yes |
| Access audio samples for in-memory consumers | Yes | Yes |
| Write publish-path samples to a native file | No | Yes |
| Start/stop file capture from React Native | No | Yes |
The upstream SDK had consumer interfaces for audio frames. It did not wire them to file I/O or expose them to React Native. The fork added that wiring without changing the core audio path.
Orchestrating the two modes
The application keeps a state machine with two recording modes: streaming-and-local, where both the LiveKit room and the file sink are active, and local-only, where LiveKit is unavailable and a direct platform recorder takes over.
stateDiagram-v2
[*] --> StreamingAndLocal
StreamingAndLocal --> LocalOnly: network / LiveKit failure
LocalOnly --> StreamingAndLocal: reconnect + safe switch
StreamingAndLocal --> Finalized: stop
LocalOnly --> Finalized: stop
The session starts in local-only mode. The file sink begins immediately. LiveKit connects in the background. Once the room is joined and the local track is publishing, the orchestrator calls AudioRecorderModule.startRecording against the fork’s sink. From that point, both paths are active.
A network failure, a room reconnect timeout, or a track loss event drops back to local-only. The file on disk is already accumulating samples. The downgrade is silent to the user. The session keeps going.
The cost of forking
A private fork of a native SDK carries a fixed maintenance overhead that compounds with every upstream release. Version pinning in package.json with a local or private registry path means the accidental npm install livekit-client-sdk-react-native will install a different package than the one in use. The package names need to differ, and every developer on the project needs to know which registry to pull from.
Upstream merges require manual review against the native layer. A LiveKit upgrade that changes the AudioSink interface or the RTCAudioRenderer contract breaks the fork silently at the Kotlin or Swift level, not at the TypeScript boundary. The build fails with a native compile error rather than a clear mismatch message.
Native changes also require an app store release to ship. An over-the-air JavaScript update cannot reach the recording path. If the sink has a bug, the fix waits for a build cycle.
The delta between the fork and upstream should be documented explicitly. A comment block in each platform module listing every change is cheap to write and saves hours when the next LiveKit update arrives. Without it, the fork becomes archaeology.
The tradeoff made sense here because the API surface is small, the alternatives all had worse failure modes, and the capability is product-specific enough that upstreaming it would require a design process we did not have time for. A team without a mobile engineer who can maintain the native layer should weigh that cost before taking the same path.