From f3b3fab4b93f5180534962b5104c05d8d3946c45 Mon Sep 17 00:00:00 2001 From: Kewal Date: Sat, 11 Apr 2026 00:55:47 +0530 Subject: [PATCH 1/5] fix: add iOS media recorder preconnect diagnostics Mirror native preconnect timing into JS and cover the recorder flow with tests so early-recording behavior is easier to validate on iOS. --- ios/LiveKitReactNativeModule.swift | 9 ++ ios/audio/AudioSinkRenderer.swift | 19 +++-- src/audio/MediaRecorder.test.ts | 128 +++++++++++++++++++++++++++++ src/audio/MediaRecorder.ts | 73 +++++++++++++++- src/events/EventEmitter.ts | 1 + 5 files changed, 223 insertions(+), 7 deletions(-) create mode 100644 src/audio/MediaRecorder.test.ts diff --git a/ios/LiveKitReactNativeModule.swift b/ios/LiveKitReactNativeModule.swift index b3c3ef47..f0972d1d 100644 --- a/ios/LiveKitReactNativeModule.swift +++ b/ios/LiveKitReactNativeModule.swift @@ -7,6 +7,7 @@ struct LKEvents { static let kEventVolumeProcessed = "LK_VOLUME_PROCESSED"; static let kEventMultibandProcessed = "LK_MULTIBAND_PROCESSED"; static let kEventAudioData = "LK_AUDIO_DATA"; + static let kEventPreconnectDebug = "LK_PRECONNECT_DEBUG"; } @objc(LivekitReactNativeModule) @@ -182,6 +183,13 @@ public class LivekitReactNativeModule: RCTEventEmitter { let reactTag = self.audioRendererManager.registerRenderer(renderer) renderer.reactTag = reactTag self.audioRendererManager.attach(renderer: renderer, pcId: pcId, trackId: trackId) + self.sendEvent(withName: LKEvents.kEventPreconnectDebug, body: [ + "id": reactTag, + "pcId": pcId, + "stage": "native_audio_sink_listener_attached", + "trackId": trackId, + "timestampMs": Int(Date().timeIntervalSince1970 * 1000) + ]) return reactTag } @@ -254,6 +262,7 @@ public class LivekitReactNativeModule: RCTEventEmitter { LKEvents.kEventVolumeProcessed, LKEvents.kEventMultibandProcessed, LKEvents.kEventAudioData, + LKEvents.kEventPreconnectDebug, ] } } diff --git a/ios/audio/AudioSinkRenderer.swift b/ios/audio/AudioSinkRenderer.swift index e3dbc65d..afd45c28 100644 --- a/ios/audio/AudioSinkRenderer.swift +++ b/ios/audio/AudioSinkRenderer.swift @@ -4,6 +4,7 @@ import React @objc public class AudioSinkRenderer: BaseAudioSinkRenderer { private let eventEmitter: RCTEventEmitter + private var hasSentFirstPreconnectDebug = false @objc public var reactTag: String? = nil @@ -24,11 +25,19 @@ public class AudioSinkRenderer: BaseAudioSinkRenderer { let length = Int(pcmBuffer.frameCapacity * pcmBuffer.format.streamDescription.pointee.mBytesPerFrame) let data = NSData(bytes: channels[0], length: length) let base64 = data.base64EncodedString() - NSLog("AUDIO DATA!!!!") - NSLog("\(data.length)") - NSLog(base64) - NSLog("\(base64.count)") - NSLog("\(length)") + if !hasSentFirstPreconnectDebug { + hasSentFirstPreconnectDebug = true + eventEmitter.sendEvent(withName: LKEvents.kEventPreconnectDebug, body: [ + "base64Length": base64.count, + "byteLength": data.length, + "channels": channelCount, + "frameLength": pcmBuffer.frameLength, + "id": reactTag, + "sampleRate": pcmBuffer.format.sampleRate, + "stage": "first_native_pcm_chunk", + "timestampMs": Int(Date().timeIntervalSince1970 * 1000) + ]) + } eventEmitter.sendEvent(withName: LKEvents.kEventAudioData, body: [ "data": base64, "id": reactTag diff --git a/src/audio/MediaRecorder.test.ts b/src/audio/MediaRecorder.test.ts new file mode 100644 index 00000000..f637f895 --- /dev/null +++ b/src/audio/MediaRecorder.test.ts @@ -0,0 +1,128 @@ +import { describe, expect, jest, test } from '@jest/globals'; +import type { MediaStream } from '@livekit/react-native-webrtc'; + +jest.mock('../events/EventEmitter', () => ({ + addListener: jest.fn(), + removeListener: jest.fn(), +})); + +jest.mock('../LKNativeModule', () => ({ + __esModule: true, + default: { + createAudioSinkListener: jest.fn(() => 'react-tag-1'), + deleteAudioSinkListener: jest.fn(), + }, +})); + +import { MediaRecorder } from './MediaRecorder'; +import * as eventEmitterModule from '../events/EventEmitter'; + +const createStream = () => + ({ + getAudioTracks: () => [ + { + _peerConnectionId: 42, + id: 'audio-track-1', + }, + ], + }) as unknown as MediaStream; + +describe('MediaRecorder shim', () => { + test('reports iOS-compatible preconnect mime support', () => { + expect(MediaRecorder.isTypeSupported('video/mp4')).toBe(true); + expect(MediaRecorder.isTypeSupported('audio/webm;codecs=opus')).toBe(false); + }); + + test('honors a supported requested mime type and defaults to audio/pcm', () => { + const stream = {} as MediaStream; + + expect(new MediaRecorder(stream).mimeType).toBe('audio/pcm'); + expect(new MediaRecorder(stream, { mimeType: 'video/mp4' }).mimeType).toBe( + 'video/mp4' + ); + expect( + new MediaRecorder(stream, { mimeType: 'audio/webm;codecs=opus' }).mimeType + ).toBe('audio/pcm'); + }); + + test('logs recorder timing around start, first chunk, and first dispatch', () => { + const mockAddListener = jest.mocked(eventEmitterModule.addListener); + const consoleLogSpy = jest + .spyOn(console, 'log') + .mockImplementation(() => undefined); + const recorder = new MediaRecorder(createStream(), { + mimeType: 'video/mp4', + }); + + recorder.start(); + + const audioDataHandler = mockAddListener.mock.calls.find( + ([, eventName]) => eventName === 'LK_AUDIO_DATA' + )?.[2] as ((event: { data: string; id: string }) => void) | undefined; + + expect(audioDataHandler).toBeDefined(); + + audioDataHandler?.({ + data: 'AQID', + id: 'react-tag-1', + }); + + recorder.requestData(); + + const debugLogs = consoleLogSpy.mock.calls + .filter(([tag]) => tag === 'lk-rn-media-recorder-debug') + .map(([, payload]) => JSON.parse(String(payload)).stage); + + expect(debugLogs).toEqual([ + 'start_entered', + 'create_audio_sink_listener_requested', + 'create_audio_sink_listener_completed', + 'start_completed', + 'first_audio_chunk_received', + 'first_data_dispatched', + ]); + + consoleLogSpy.mockRestore(); + }); + + test('mirrors native preconnect debug events to the JS console timeline', () => { + const mockAddListener = jest.mocked(eventEmitterModule.addListener); + const consoleLogSpy = jest + .spyOn(console, 'log') + .mockImplementation(() => undefined); + const recorder = new MediaRecorder(createStream(), { + mimeType: 'video/mp4', + }); + + recorder.start(); + + const nativeDebugHandler = mockAddListener.mock.calls.find( + ([, eventName]) => eventName === 'LK_PRECONNECT_DEBUG' + )?.[2] as + | ((event: { id: string; stage: string; sampleRate: number }) => void) + | undefined; + + expect(nativeDebugHandler).toBeDefined(); + + nativeDebugHandler?.({ + id: 'react-tag-1', + sampleRate: 48000, + stage: 'first_native_pcm_chunk', + }); + + const nativeLogs = consoleLogSpy.mock.calls + .filter(([tag]) => tag === 'lk-rn-native-preconnect-debug') + .map(([, payload]) => JSON.parse(String(payload))); + + expect(nativeLogs).toContainEqual( + expect.objectContaining({ + mimeType: 'video/mp4', + reactTag: 'react-tag-1', + sampleRate: 48000, + stage: 'first_native_pcm_chunk', + }) + ); + + consoleLogSpy.mockRestore(); + }); +}); diff --git a/src/audio/MediaRecorder.ts b/src/audio/MediaRecorder.ts index a5ee80d5..a5def9a3 100644 --- a/src/audio/MediaRecorder.ts +++ b/src/audio/MediaRecorder.ts @@ -25,13 +25,21 @@ type MediaRecorderEventMap = { stop: Event<'stop'>; }; +type MediaRecorderOptions = { + mimeType?: string; +}; + /** * A MediaRecord implementation only meant for recording audio streams. * * @private */ export class MediaRecorder extends EventTarget { - mimeType: String = 'audio/pcm'; + static isTypeSupported(mimeType: string): boolean { + return mimeType === 'video/mp4'; + } + + mimeType: string; audioBitsPerSecond: number = 0; // TODO? state: MediaRecorderState = 'inactive'; stream: MediaStream; @@ -40,23 +48,55 @@ export class MediaRecorder extends EventTarget { _reactTag: string | undefined = undefined; _parts: string[] = []; - constructor(stream: MediaStream) { + _hasLoggedFirstChunk = false; + _hasLoggedFirstDispatch = false; + + constructor(stream: MediaStream, options?: MediaRecorderOptions) { super(); this.stream = stream; + this.mimeType = + options?.mimeType && MediaRecorder.isTypeSupported(options.mimeType) + ? options.mimeType + : 'audio/pcm'; + } + + emitDebugLog(stage: string, extra: Record = {}) { + console.log( + 'lk-rn-media-recorder-debug', + JSON.stringify({ + timestampMs: Date.now(), + stage, + mimeType: this.mimeType, + reactTag: this._reactTag, + state: this.state, + ...extra, + }) + ); } registerListener() { let audioTracks = this.stream.getAudioTracks(); if (audioTracks.length !== 1) { + this.emitDebugLog('create_audio_sink_listener_skipped', { + audioTrackCount: audioTracks.length, + }); return; } const mediaStreamTrack = audioTracks[0]!!; const peerConnectionId = mediaStreamTrack._peerConnectionId ?? -1; const mediaStreamTrackId = mediaStreamTrack?.id; + this.emitDebugLog('create_audio_sink_listener_requested', { + peerConnectionId, + trackId: mediaStreamTrackId, + }); this._reactTag = LiveKitModule.createAudioSinkListener( peerConnectionId, mediaStreamTrackId ); + this.emitDebugLog('create_audio_sink_listener_completed', { + peerConnectionId, + trackId: mediaStreamTrackId, + }); addListener(this, 'LK_AUDIO_DATA', (event: any) => { if ( this._reactTag && @@ -64,9 +104,29 @@ export class MediaRecorder extends EventTarget { this.state === 'recording' ) { let str = event.data as string; + if (!this._hasLoggedFirstChunk) { + this._hasLoggedFirstChunk = true; + this.emitDebugLog('first_audio_chunk_received', { + base64Length: str.length, + }); + } this._parts.push(str); } }); + addListener(this, 'LK_PRECONNECT_DEBUG', (event: any) => { + if (this._reactTag && event.id === this._reactTag) { + console.log( + 'lk-rn-native-preconnect-debug', + JSON.stringify({ + timestampMs: Date.now(), + mimeType: this.mimeType, + reactTag: this._reactTag, + state: this.state, + ...event, + }) + ); + } + }); } unregisterListener() { @@ -99,9 +159,11 @@ export class MediaRecorder extends EventTarget { } start() { + this.emitDebugLog('start_entered'); this.registerListener(); this.state = 'recording'; this.dispatchEvent(new Event('start')); + this.emitDebugLog('start_completed'); } stop() { @@ -116,10 +178,17 @@ export class MediaRecorder extends EventTarget { requestData() { this.dispatchData(); } + dispatchData() { let combinedStr = this._parts.reduce((sum, cur) => sum + cur, ''); let data = toByteArray(combinedStr); this._parts = []; + if (!this._hasLoggedFirstDispatch) { + this._hasLoggedFirstDispatch = true; + this.emitDebugLog('first_data_dispatched', { + byteLength: data.length, + }); + } this.dispatchEvent( new BlobEvent('dataavailable', { data: { byteArray: data } }) ); diff --git a/src/events/EventEmitter.ts b/src/events/EventEmitter.ts index 881cec38..f60b3e65 100644 --- a/src/events/EventEmitter.ts +++ b/src/events/EventEmitter.ts @@ -11,6 +11,7 @@ const NATIVE_EVENTS = [ 'LK_VOLUME_PROCESSED', 'LK_MULTIBAND_PROCESSED', 'LK_AUDIO_DATA', + 'LK_PRECONNECT_DEBUG', ]; const eventEmitter = new EventEmitter(); From dc438cf78e4558def3c1f734e486910dab0cc02a Mon Sep 17 00:00:00 2001 From: Kewal Date: Sat, 11 Apr 2026 01:05:28 +0530 Subject: [PATCH 2/5] feat: add ios local recording controls --- ios/Headers/LKAudioProcessingManager.h | 8 +++ ios/LKAudioProcessingManager.m | 69 ++++++++++++++++++++ ios/LiveKitReactNativeModule.swift | 89 ++++++++++++++++++++++++++ ios/LivekitReactNativeModule.m | 4 ++ src/audio/AudioSession.test.ts | 33 ++++++++++ src/audio/AudioSession.ts | 17 +++++ src/audio/MediaRecorder.test.ts | 16 +++++ src/events/EventEmitter.ts | 2 + 8 files changed, 238 insertions(+) create mode 100644 src/audio/AudioSession.test.ts diff --git a/ios/Headers/LKAudioProcessingManager.h b/ios/Headers/LKAudioProcessingManager.h index 332b85c5..e105a51b 100644 --- a/ios/Headers/LKAudioProcessingManager.h +++ b/ios/Headers/LKAudioProcessingManager.h @@ -6,6 +6,8 @@ @property(nonatomic, strong) RTCDefaultAudioProcessingModule* _Nonnull audioProcessingModule; +@property(nonatomic, strong, nullable) RTCAudioDeviceModule* audioDeviceModule; + @property(nonatomic, strong) LKAudioProcessingAdapter* _Nonnull capturePostProcessingAdapter; @property(nonatomic, strong) LKAudioProcessingAdapter* _Nonnull renderPreProcessingAdapter; @@ -31,4 +33,10 @@ - (void)clearProcessors; +- (BOOL)startLocalRecording:(NSError * _Nullable * _Nullable)error + NS_SWIFT_NAME(startLocalRecording()); + +- (BOOL)stopLocalRecording:(NSError * _Nullable * _Nullable)error + NS_SWIFT_NAME(stopLocalRecording()); + @end diff --git a/ios/LKAudioProcessingManager.m b/ios/LKAudioProcessingManager.m index 083fa778..b6cac44f 100644 --- a/ios/LKAudioProcessingManager.m +++ b/ios/LKAudioProcessingManager.m @@ -1,6 +1,8 @@ #import "LKAudioProcessingManager.h" #import "LKAudioProcessingAdapter.h" +static NSString *const LKAudioProcessingManagerErrorDomain = @"LKAudioProcessingManagerErrorDomain"; + @implementation LKAudioProcessingManager + (instancetype)sharedInstance { @@ -59,5 +61,72 @@ - (void)clearProcessors { // TODO } +- (BOOL)startLocalRecording:(NSError * _Nullable * _Nullable)error { + if (self.audioDeviceModule == nil) { + if (error != nil) { + *error = [NSError errorWithDomain:LKAudioProcessingManagerErrorDomain + code:-1 + userInfo:@{ + NSLocalizedDescriptionKey : @"Audio device module is unavailable", + }]; + } + return NO; + } + + if (self.audioDeviceModule.isRecording) { + return YES; + } + + NSInteger status = self.audioDeviceModule.isRecordingInitialized + ? [self.audioDeviceModule startRecording] + : [self.audioDeviceModule initAndStartRecording]; + if (status != 0) { + if (error != nil) { + *error = [NSError errorWithDomain:LKAudioProcessingManagerErrorDomain + code:status + userInfo:@{ + NSLocalizedDescriptionKey : + [NSString stringWithFormat:@"Failed to start local recording (status %ld)", + (long)status], + }]; + } + return NO; + } + + return YES; +} + +- (BOOL)stopLocalRecording:(NSError * _Nullable * _Nullable)error { + if (self.audioDeviceModule == nil) { + if (error != nil) { + *error = [NSError errorWithDomain:LKAudioProcessingManagerErrorDomain + code:-1 + userInfo:@{ + NSLocalizedDescriptionKey : @"Audio device module is unavailable", + }]; + } + return NO; + } + + if (!self.audioDeviceModule.isRecording) { + return YES; + } + + NSInteger status = [self.audioDeviceModule stopRecording]; + if (status != 0) { + if (error != nil) { + *error = [NSError errorWithDomain:LKAudioProcessingManagerErrorDomain + code:status + userInfo:@{ + NSLocalizedDescriptionKey : + [NSString stringWithFormat:@"Failed to stop local recording (status %ld)", + (long)status], + }]; + } + return NO; + } + + return YES; +} @end diff --git a/ios/LiveKitReactNativeModule.swift b/ios/LiveKitReactNativeModule.swift index f0972d1d..9c3dd173 100644 --- a/ios/LiveKitReactNativeModule.swift +++ b/ios/LiveKitReactNativeModule.swift @@ -8,6 +8,7 @@ struct LKEvents { static let kEventMultibandProcessed = "LK_MULTIBAND_PROCESSED"; static let kEventAudioData = "LK_AUDIO_DATA"; static let kEventPreconnectDebug = "LK_PRECONNECT_DEBUG"; + static let kEventAudioRecordingState = "LK_AUDIO_RECORDING_STATE"; } @objc(LivekitReactNativeModule) @@ -104,6 +105,93 @@ public class LivekitReactNativeModule: RCTEventEmitter { } } + private func syncAudioDeviceModule() -> Bool { + guard let webRTCModule = self.bridge.module(for: WebRTCModule.self) as? WebRTCModule else { + return false + } + + LKAudioProcessingManager.sharedInstance().audioDeviceModule = + webRTCModule.peerConnectionFactory.audioDeviceModule + return true + } + + @objc(startLocalRecording:withRejecter:) + public func startLocalRecording(resolve: RCTPromiseResolveBlock, reject: RCTPromiseRejectBlock) { + self.sendEvent(withName: LKEvents.kEventAudioRecordingState, body: [ + "stage": "local_recording_start_requested", + "timestampMs": Int(Date().timeIntervalSince1970 * 1000), + ]) + + guard syncAudioDeviceModule() else { + let bridgeError = NSError( + domain: "LivekitReactNativeModule", + code: -1, + userInfo: [ + NSLocalizedDescriptionKey: + "WebRTCModule is unavailable while starting local recording", + ] + ) + self.sendEvent(withName: LKEvents.kEventAudioRecordingState, body: [ + "stage": "local_recording_start_failed", + "timestampMs": Int(Date().timeIntervalSince1970 * 1000), + "error": bridgeError.localizedDescription, + ]) + reject("startLocalRecording", bridgeError.localizedDescription, bridgeError) + return + } + + do { + try LKAudioProcessingManager.sharedInstance().startLocalRecording() + self.sendEvent(withName: LKEvents.kEventAudioRecordingState, body: [ + "stage": "local_recording_started", + "timestampMs": Int(Date().timeIntervalSince1970 * 1000), + ]) + resolve(nil) + } catch { + self.sendEvent(withName: LKEvents.kEventAudioRecordingState, body: [ + "stage": "local_recording_start_failed", + "timestampMs": Int(Date().timeIntervalSince1970 * 1000), + "error": error.localizedDescription, + ]) + reject( + "startLocalRecording", + "Error starting local recording: \(error.localizedDescription)", + error + ) + } + } + + @objc(stopLocalRecording:withRejecter:) + public func stopLocalRecording(resolve: RCTPromiseResolveBlock, reject: RCTPromiseRejectBlock) { + guard syncAudioDeviceModule() else { + let bridgeError = NSError( + domain: "LivekitReactNativeModule", + code: -1, + userInfo: [ + NSLocalizedDescriptionKey: + "WebRTCModule is unavailable while stopping local recording", + ] + ) + reject("stopLocalRecording", bridgeError.localizedDescription, bridgeError) + return + } + + do { + try LKAudioProcessingManager.sharedInstance().stopLocalRecording() + self.sendEvent(withName: LKEvents.kEventAudioRecordingState, body: [ + "stage": "local_recording_stopped", + "timestampMs": Int(Date().timeIntervalSince1970 * 1000), + ]) + resolve(nil) + } catch { + reject( + "stopLocalRecording", + "Error stopping local recording: \(error.localizedDescription)", + error + ) + } + } + @objc(showAudioRoutePicker) public func showAudioRoutePicker() { if #available(iOS 11.0, *) { @@ -263,6 +351,7 @@ public class LivekitReactNativeModule: RCTEventEmitter { LKEvents.kEventMultibandProcessed, LKEvents.kEventAudioData, LKEvents.kEventPreconnectDebug, + LKEvents.kEventAudioRecordingState, ] } } diff --git a/ios/LivekitReactNativeModule.m b/ios/LivekitReactNativeModule.m index dfe83d6c..d582fad4 100644 --- a/ios/LivekitReactNativeModule.m +++ b/ios/LivekitReactNativeModule.m @@ -9,6 +9,10 @@ @interface RCT_EXTERN_MODULE(LivekitReactNativeModule, RCTEventEmitter) withRejecter:(RCTPromiseRejectBlock)reject) RCT_EXTERN_METHOD(stopAudioSession:(RCTPromiseResolveBlock)resolve withRejecter:(RCTPromiseRejectBlock)reject) +RCT_EXTERN_METHOD(startLocalRecording:(RCTPromiseResolveBlock)resolve + withRejecter:(RCTPromiseRejectBlock)reject) +RCT_EXTERN_METHOD(stopLocalRecording:(RCTPromiseResolveBlock)resolve + withRejecter:(RCTPromiseRejectBlock)reject) RCT_EXTERN_METHOD(setDefaultAudioTrackVolume:(nonnull NSNumber *) volume) diff --git a/src/audio/AudioSession.test.ts b/src/audio/AudioSession.test.ts new file mode 100644 index 00000000..821a5241 --- /dev/null +++ b/src/audio/AudioSession.test.ts @@ -0,0 +1,33 @@ +import { beforeEach, describe, expect, jest, test } from '@jest/globals'; + +jest.mock('../LKNativeModule', () => ({ + __esModule: true, + default: { + configureAudio: jest.fn(), + startAudioSession: jest.fn(), + stopAudioSession: jest.fn(), + startLocalRecording: jest.fn(), + stopLocalRecording: jest.fn(), + }, +})); + +import LiveKitModule from '../LKNativeModule'; +import AudioSession from './AudioSession'; + +describe('AudioSession local recording', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + test('delegates startLocalRecording to the native module', async () => { + await AudioSession.startLocalRecording(); + + expect(LiveKitModule.startLocalRecording).toHaveBeenCalledTimes(1); + }); + + test('delegates stopLocalRecording to the native module', async () => { + await AudioSession.stopLocalRecording(); + + expect(LiveKitModule.stopLocalRecording).toHaveBeenCalledTimes(1); + }); +}); diff --git a/src/audio/AudioSession.ts b/src/audio/AudioSession.ts index ed3b9a9c..5dc981bd 100644 --- a/src/audio/AudioSession.ts +++ b/src/audio/AudioSession.ts @@ -254,6 +254,23 @@ export default class AudioSession { await LiveKitModule.stopAudioSession(); }; + /** + * Starts local microphone recording on iOS prior to track publish/connect. + * + * Intended for low-latency preconnect and similar workflows. + */ + static startLocalRecording = async () => { + await LiveKitModule.startLocalRecording(); + }; + + /** + * Stops local microphone recording that was started explicitly with + * {@link startLocalRecording}. + */ + static stopLocalRecording = async () => { + await LiveKitModule.stopLocalRecording(); + }; + /** * Set default audio track volume when new tracks are subscribed. * Does **not** affect any existing tracks. diff --git a/src/audio/MediaRecorder.test.ts b/src/audio/MediaRecorder.test.ts index f637f895..547cd326 100644 --- a/src/audio/MediaRecorder.test.ts +++ b/src/audio/MediaRecorder.test.ts @@ -9,8 +9,10 @@ jest.mock('../events/EventEmitter', () => ({ jest.mock('../LKNativeModule', () => ({ __esModule: true, default: { + addListener: jest.fn(), createAudioSinkListener: jest.fn(() => 'react-tag-1'), deleteAudioSinkListener: jest.fn(), + removeListeners: jest.fn(), }, })); @@ -125,4 +127,18 @@ describe('MediaRecorder shim', () => { consoleLogSpy.mockRestore(); }); + + test('accepts audio recording state events from the native emitter registry', () => { + const actualEventEmitterModule = jest.requireActual< + typeof import('../events/EventEmitter') + >('../events/EventEmitter'); + + expect(() => + actualEventEmitterModule.addListener( + {}, + 'LK_AUDIO_RECORDING_STATE', + jest.fn() + ) + ).not.toThrow(); + }); }); diff --git a/src/events/EventEmitter.ts b/src/events/EventEmitter.ts index f60b3e65..0954c0da 100644 --- a/src/events/EventEmitter.ts +++ b/src/events/EventEmitter.ts @@ -1,5 +1,6 @@ import { NativeEventEmitter, type EmitterSubscription } from 'react-native'; // @ts-ignore +// eslint-disable-next-line @react-native/no-deep-imports -- React Native does not expose this emitter at the top level. import EventEmitter from 'react-native/Libraries/vendor/emitter/EventEmitter'; import LiveKitModule from '../LKNativeModule'; @@ -12,6 +13,7 @@ const NATIVE_EVENTS = [ 'LK_MULTIBAND_PROCESSED', 'LK_AUDIO_DATA', 'LK_PRECONNECT_DEBUG', + 'LK_AUDIO_RECORDING_STATE', ]; const eventEmitter = new EventEmitter(); From 7b4b39cbc6bbed4d34c589d0b448390da44fa3d2 Mon Sep 17 00:00:00 2001 From: Kewal Date: Sat, 11 Apr 2026 13:43:16 +0530 Subject: [PATCH 3/5] fix: rebase media recorder diagnostics onto main --- src/audio/MediaRecorder.ts | 67 ++++++++------------------------------ 1 file changed, 13 insertions(+), 54 deletions(-) diff --git a/src/audio/MediaRecorder.ts b/src/audio/MediaRecorder.ts index a5def9a3..bd847305 100644 --- a/src/audio/MediaRecorder.ts +++ b/src/audio/MediaRecorder.ts @@ -1,11 +1,6 @@ import type { MediaStream } from '@livekit/react-native-webrtc'; -import { - EventTarget, - Event, - getEventAttributeValue, - setEventAttributeValue, -} from '@livekit/react-native-webrtc'; import { addListener } from '../events/EventEmitter'; +import { EventTarget, Event, defineEventAttribute } from 'event-target-shim'; import { toByteArray } from 'base64-js'; import LiveKitModule from '../LKNativeModule'; import { log } from '../logger'; @@ -193,54 +188,6 @@ export class MediaRecorder extends EventTarget { new BlobEvent('dataavailable', { data: { byteArray: data } }) ); } - - get ondataavailable() { - return getEventAttributeValue(this, 'dataavailable'); - } - - set ondataavailable(value) { - setEventAttributeValue(this, 'dataavailable', value); - } - - get onerror() { - return getEventAttributeValue(this, 'error'); - } - - set onerror(value) { - setEventAttributeValue(this, 'error', value); - } - - get onpause() { - return getEventAttributeValue(this, 'pause'); - } - - set onpause(value) { - setEventAttributeValue(this, 'pause', value); - } - - get onresume() { - return getEventAttributeValue(this, 'resume'); - } - - set onresume(value) { - setEventAttributeValue(this, 'resume', value); - } - - get onstart() { - return getEventAttributeValue(this, 'start'); - } - - set onstart(value) { - setEventAttributeValue(this, 'start', value); - } - - get onstop() { - return getEventAttributeValue(this, 'stop'); - } - - set onstop(value) { - setEventAttributeValue(this, 'stop', value); - } } /** @@ -262,3 +209,15 @@ class BlobEvent extends Event { this.data = eventInitDict.data; } } + +/** + * Define the `onxxx` event handlers. + */ +const proto = MediaRecorder.prototype; + +defineEventAttribute(proto, 'dataavailable'); +defineEventAttribute(proto, 'error'); +defineEventAttribute(proto, 'pause'); +defineEventAttribute(proto, 'resume'); +defineEventAttribute(proto, 'start'); +defineEventAttribute(proto, 'stop'); From ad7acf4efd8caed5164eacedd0c18fcb3564e303 Mon Sep 17 00:00:00 2001 From: Kewal Date: Sat, 11 Apr 2026 14:26:53 +0530 Subject: [PATCH 4/5] chore: remove local recording diagnostics --- ios/LiveKitReactNativeModule.swift | 9 -- ios/audio/AudioSinkRenderer.swift | 14 --- src/audio/AudioSession.test.ts | 16 ++++ src/audio/MediaRecorder.test.ts | 144 ----------------------------- src/audio/MediaRecorder.ts | 140 +++++++++++----------------- src/events/EventEmitter.ts | 1 - 6 files changed, 72 insertions(+), 252 deletions(-) delete mode 100644 src/audio/MediaRecorder.test.ts diff --git a/ios/LiveKitReactNativeModule.swift b/ios/LiveKitReactNativeModule.swift index 9c3dd173..5a778645 100644 --- a/ios/LiveKitReactNativeModule.swift +++ b/ios/LiveKitReactNativeModule.swift @@ -7,7 +7,6 @@ struct LKEvents { static let kEventVolumeProcessed = "LK_VOLUME_PROCESSED"; static let kEventMultibandProcessed = "LK_MULTIBAND_PROCESSED"; static let kEventAudioData = "LK_AUDIO_DATA"; - static let kEventPreconnectDebug = "LK_PRECONNECT_DEBUG"; static let kEventAudioRecordingState = "LK_AUDIO_RECORDING_STATE"; } @@ -271,13 +270,6 @@ public class LivekitReactNativeModule: RCTEventEmitter { let reactTag = self.audioRendererManager.registerRenderer(renderer) renderer.reactTag = reactTag self.audioRendererManager.attach(renderer: renderer, pcId: pcId, trackId: trackId) - self.sendEvent(withName: LKEvents.kEventPreconnectDebug, body: [ - "id": reactTag, - "pcId": pcId, - "stage": "native_audio_sink_listener_attached", - "trackId": trackId, - "timestampMs": Int(Date().timeIntervalSince1970 * 1000) - ]) return reactTag } @@ -350,7 +342,6 @@ public class LivekitReactNativeModule: RCTEventEmitter { LKEvents.kEventVolumeProcessed, LKEvents.kEventMultibandProcessed, LKEvents.kEventAudioData, - LKEvents.kEventPreconnectDebug, LKEvents.kEventAudioRecordingState, ] } diff --git a/ios/audio/AudioSinkRenderer.swift b/ios/audio/AudioSinkRenderer.swift index afd45c28..c5d99c6a 100644 --- a/ios/audio/AudioSinkRenderer.swift +++ b/ios/audio/AudioSinkRenderer.swift @@ -4,7 +4,6 @@ import React @objc public class AudioSinkRenderer: BaseAudioSinkRenderer { private let eventEmitter: RCTEventEmitter - private var hasSentFirstPreconnectDebug = false @objc public var reactTag: String? = nil @@ -25,19 +24,6 @@ public class AudioSinkRenderer: BaseAudioSinkRenderer { let length = Int(pcmBuffer.frameCapacity * pcmBuffer.format.streamDescription.pointee.mBytesPerFrame) let data = NSData(bytes: channels[0], length: length) let base64 = data.base64EncodedString() - if !hasSentFirstPreconnectDebug { - hasSentFirstPreconnectDebug = true - eventEmitter.sendEvent(withName: LKEvents.kEventPreconnectDebug, body: [ - "base64Length": base64.count, - "byteLength": data.length, - "channels": channelCount, - "frameLength": pcmBuffer.frameLength, - "id": reactTag, - "sampleRate": pcmBuffer.format.sampleRate, - "stage": "first_native_pcm_chunk", - "timestampMs": Int(Date().timeIntervalSince1970 * 1000) - ]) - } eventEmitter.sendEvent(withName: LKEvents.kEventAudioData, body: [ "data": base64, "id": reactTag diff --git a/src/audio/AudioSession.test.ts b/src/audio/AudioSession.test.ts index 821a5241..c8f1efea 100644 --- a/src/audio/AudioSession.test.ts +++ b/src/audio/AudioSession.test.ts @@ -3,7 +3,9 @@ import { beforeEach, describe, expect, jest, test } from '@jest/globals'; jest.mock('../LKNativeModule', () => ({ __esModule: true, default: { + addListener: jest.fn(), configureAudio: jest.fn(), + removeListeners: jest.fn(), startAudioSession: jest.fn(), stopAudioSession: jest.fn(), startLocalRecording: jest.fn(), @@ -30,4 +32,18 @@ describe('AudioSession local recording', () => { expect(LiveKitModule.stopLocalRecording).toHaveBeenCalledTimes(1); }); + + test('accepts audio recording state events from the native emitter registry', () => { + const actualEventEmitterModule = jest.requireActual< + typeof import('../events/EventEmitter') + >('../events/EventEmitter'); + + expect(() => + actualEventEmitterModule.addListener( + {}, + 'LK_AUDIO_RECORDING_STATE', + jest.fn() + ) + ).not.toThrow(); + }); }); diff --git a/src/audio/MediaRecorder.test.ts b/src/audio/MediaRecorder.test.ts deleted file mode 100644 index 547cd326..00000000 --- a/src/audio/MediaRecorder.test.ts +++ /dev/null @@ -1,144 +0,0 @@ -import { describe, expect, jest, test } from '@jest/globals'; -import type { MediaStream } from '@livekit/react-native-webrtc'; - -jest.mock('../events/EventEmitter', () => ({ - addListener: jest.fn(), - removeListener: jest.fn(), -})); - -jest.mock('../LKNativeModule', () => ({ - __esModule: true, - default: { - addListener: jest.fn(), - createAudioSinkListener: jest.fn(() => 'react-tag-1'), - deleteAudioSinkListener: jest.fn(), - removeListeners: jest.fn(), - }, -})); - -import { MediaRecorder } from './MediaRecorder'; -import * as eventEmitterModule from '../events/EventEmitter'; - -const createStream = () => - ({ - getAudioTracks: () => [ - { - _peerConnectionId: 42, - id: 'audio-track-1', - }, - ], - }) as unknown as MediaStream; - -describe('MediaRecorder shim', () => { - test('reports iOS-compatible preconnect mime support', () => { - expect(MediaRecorder.isTypeSupported('video/mp4')).toBe(true); - expect(MediaRecorder.isTypeSupported('audio/webm;codecs=opus')).toBe(false); - }); - - test('honors a supported requested mime type and defaults to audio/pcm', () => { - const stream = {} as MediaStream; - - expect(new MediaRecorder(stream).mimeType).toBe('audio/pcm'); - expect(new MediaRecorder(stream, { mimeType: 'video/mp4' }).mimeType).toBe( - 'video/mp4' - ); - expect( - new MediaRecorder(stream, { mimeType: 'audio/webm;codecs=opus' }).mimeType - ).toBe('audio/pcm'); - }); - - test('logs recorder timing around start, first chunk, and first dispatch', () => { - const mockAddListener = jest.mocked(eventEmitterModule.addListener); - const consoleLogSpy = jest - .spyOn(console, 'log') - .mockImplementation(() => undefined); - const recorder = new MediaRecorder(createStream(), { - mimeType: 'video/mp4', - }); - - recorder.start(); - - const audioDataHandler = mockAddListener.mock.calls.find( - ([, eventName]) => eventName === 'LK_AUDIO_DATA' - )?.[2] as ((event: { data: string; id: string }) => void) | undefined; - - expect(audioDataHandler).toBeDefined(); - - audioDataHandler?.({ - data: 'AQID', - id: 'react-tag-1', - }); - - recorder.requestData(); - - const debugLogs = consoleLogSpy.mock.calls - .filter(([tag]) => tag === 'lk-rn-media-recorder-debug') - .map(([, payload]) => JSON.parse(String(payload)).stage); - - expect(debugLogs).toEqual([ - 'start_entered', - 'create_audio_sink_listener_requested', - 'create_audio_sink_listener_completed', - 'start_completed', - 'first_audio_chunk_received', - 'first_data_dispatched', - ]); - - consoleLogSpy.mockRestore(); - }); - - test('mirrors native preconnect debug events to the JS console timeline', () => { - const mockAddListener = jest.mocked(eventEmitterModule.addListener); - const consoleLogSpy = jest - .spyOn(console, 'log') - .mockImplementation(() => undefined); - const recorder = new MediaRecorder(createStream(), { - mimeType: 'video/mp4', - }); - - recorder.start(); - - const nativeDebugHandler = mockAddListener.mock.calls.find( - ([, eventName]) => eventName === 'LK_PRECONNECT_DEBUG' - )?.[2] as - | ((event: { id: string; stage: string; sampleRate: number }) => void) - | undefined; - - expect(nativeDebugHandler).toBeDefined(); - - nativeDebugHandler?.({ - id: 'react-tag-1', - sampleRate: 48000, - stage: 'first_native_pcm_chunk', - }); - - const nativeLogs = consoleLogSpy.mock.calls - .filter(([tag]) => tag === 'lk-rn-native-preconnect-debug') - .map(([, payload]) => JSON.parse(String(payload))); - - expect(nativeLogs).toContainEqual( - expect.objectContaining({ - mimeType: 'video/mp4', - reactTag: 'react-tag-1', - sampleRate: 48000, - stage: 'first_native_pcm_chunk', - }) - ); - - consoleLogSpy.mockRestore(); - }); - - test('accepts audio recording state events from the native emitter registry', () => { - const actualEventEmitterModule = jest.requireActual< - typeof import('../events/EventEmitter') - >('../events/EventEmitter'); - - expect(() => - actualEventEmitterModule.addListener( - {}, - 'LK_AUDIO_RECORDING_STATE', - jest.fn() - ) - ).not.toThrow(); - }); -}); diff --git a/src/audio/MediaRecorder.ts b/src/audio/MediaRecorder.ts index bd847305..a5ee80d5 100644 --- a/src/audio/MediaRecorder.ts +++ b/src/audio/MediaRecorder.ts @@ -1,6 +1,11 @@ import type { MediaStream } from '@livekit/react-native-webrtc'; +import { + EventTarget, + Event, + getEventAttributeValue, + setEventAttributeValue, +} from '@livekit/react-native-webrtc'; import { addListener } from '../events/EventEmitter'; -import { EventTarget, Event, defineEventAttribute } from 'event-target-shim'; import { toByteArray } from 'base64-js'; import LiveKitModule from '../LKNativeModule'; import { log } from '../logger'; @@ -20,21 +25,13 @@ type MediaRecorderEventMap = { stop: Event<'stop'>; }; -type MediaRecorderOptions = { - mimeType?: string; -}; - /** * A MediaRecord implementation only meant for recording audio streams. * * @private */ export class MediaRecorder extends EventTarget { - static isTypeSupported(mimeType: string): boolean { - return mimeType === 'video/mp4'; - } - - mimeType: string; + mimeType: String = 'audio/pcm'; audioBitsPerSecond: number = 0; // TODO? state: MediaRecorderState = 'inactive'; stream: MediaStream; @@ -43,55 +40,23 @@ export class MediaRecorder extends EventTarget { _reactTag: string | undefined = undefined; _parts: string[] = []; - _hasLoggedFirstChunk = false; - _hasLoggedFirstDispatch = false; - - constructor(stream: MediaStream, options?: MediaRecorderOptions) { + constructor(stream: MediaStream) { super(); this.stream = stream; - this.mimeType = - options?.mimeType && MediaRecorder.isTypeSupported(options.mimeType) - ? options.mimeType - : 'audio/pcm'; - } - - emitDebugLog(stage: string, extra: Record = {}) { - console.log( - 'lk-rn-media-recorder-debug', - JSON.stringify({ - timestampMs: Date.now(), - stage, - mimeType: this.mimeType, - reactTag: this._reactTag, - state: this.state, - ...extra, - }) - ); } registerListener() { let audioTracks = this.stream.getAudioTracks(); if (audioTracks.length !== 1) { - this.emitDebugLog('create_audio_sink_listener_skipped', { - audioTrackCount: audioTracks.length, - }); return; } const mediaStreamTrack = audioTracks[0]!!; const peerConnectionId = mediaStreamTrack._peerConnectionId ?? -1; const mediaStreamTrackId = mediaStreamTrack?.id; - this.emitDebugLog('create_audio_sink_listener_requested', { - peerConnectionId, - trackId: mediaStreamTrackId, - }); this._reactTag = LiveKitModule.createAudioSinkListener( peerConnectionId, mediaStreamTrackId ); - this.emitDebugLog('create_audio_sink_listener_completed', { - peerConnectionId, - trackId: mediaStreamTrackId, - }); addListener(this, 'LK_AUDIO_DATA', (event: any) => { if ( this._reactTag && @@ -99,29 +64,9 @@ export class MediaRecorder extends EventTarget { this.state === 'recording' ) { let str = event.data as string; - if (!this._hasLoggedFirstChunk) { - this._hasLoggedFirstChunk = true; - this.emitDebugLog('first_audio_chunk_received', { - base64Length: str.length, - }); - } this._parts.push(str); } }); - addListener(this, 'LK_PRECONNECT_DEBUG', (event: any) => { - if (this._reactTag && event.id === this._reactTag) { - console.log( - 'lk-rn-native-preconnect-debug', - JSON.stringify({ - timestampMs: Date.now(), - mimeType: this.mimeType, - reactTag: this._reactTag, - state: this.state, - ...event, - }) - ); - } - }); } unregisterListener() { @@ -154,11 +99,9 @@ export class MediaRecorder extends EventTarget { } start() { - this.emitDebugLog('start_entered'); this.registerListener(); this.state = 'recording'; this.dispatchEvent(new Event('start')); - this.emitDebugLog('start_completed'); } stop() { @@ -173,21 +116,62 @@ export class MediaRecorder extends EventTarget { requestData() { this.dispatchData(); } - dispatchData() { let combinedStr = this._parts.reduce((sum, cur) => sum + cur, ''); let data = toByteArray(combinedStr); this._parts = []; - if (!this._hasLoggedFirstDispatch) { - this._hasLoggedFirstDispatch = true; - this.emitDebugLog('first_data_dispatched', { - byteLength: data.length, - }); - } this.dispatchEvent( new BlobEvent('dataavailable', { data: { byteArray: data } }) ); } + + get ondataavailable() { + return getEventAttributeValue(this, 'dataavailable'); + } + + set ondataavailable(value) { + setEventAttributeValue(this, 'dataavailable', value); + } + + get onerror() { + return getEventAttributeValue(this, 'error'); + } + + set onerror(value) { + setEventAttributeValue(this, 'error', value); + } + + get onpause() { + return getEventAttributeValue(this, 'pause'); + } + + set onpause(value) { + setEventAttributeValue(this, 'pause', value); + } + + get onresume() { + return getEventAttributeValue(this, 'resume'); + } + + set onresume(value) { + setEventAttributeValue(this, 'resume', value); + } + + get onstart() { + return getEventAttributeValue(this, 'start'); + } + + set onstart(value) { + setEventAttributeValue(this, 'start', value); + } + + get onstop() { + return getEventAttributeValue(this, 'stop'); + } + + set onstop(value) { + setEventAttributeValue(this, 'stop', value); + } } /** @@ -209,15 +193,3 @@ class BlobEvent extends Event { this.data = eventInitDict.data; } } - -/** - * Define the `onxxx` event handlers. - */ -const proto = MediaRecorder.prototype; - -defineEventAttribute(proto, 'dataavailable'); -defineEventAttribute(proto, 'error'); -defineEventAttribute(proto, 'pause'); -defineEventAttribute(proto, 'resume'); -defineEventAttribute(proto, 'start'); -defineEventAttribute(proto, 'stop'); diff --git a/src/events/EventEmitter.ts b/src/events/EventEmitter.ts index 0954c0da..a582d929 100644 --- a/src/events/EventEmitter.ts +++ b/src/events/EventEmitter.ts @@ -12,7 +12,6 @@ const NATIVE_EVENTS = [ 'LK_VOLUME_PROCESSED', 'LK_MULTIBAND_PROCESSED', 'LK_AUDIO_DATA', - 'LK_PRECONNECT_DEBUG', 'LK_AUDIO_RECORDING_STATE', ]; From 1f42ace22e1fc81716447724678a1e70ac2ca350 Mon Sep 17 00:00:00 2001 From: Kewal Date: Sat, 11 Apr 2026 14:54:58 +0530 Subject: [PATCH 5/5] chore: simplification --- ios/LKAudioProcessingManager.m | 32 ++++++------- ios/LiveKitReactNativeModule.swift | 77 ++++++++++++++---------------- 2 files changed, 53 insertions(+), 56 deletions(-) diff --git a/ios/LKAudioProcessingManager.m b/ios/LKAudioProcessingManager.m index b6cac44f..e95db0b4 100644 --- a/ios/LKAudioProcessingManager.m +++ b/ios/LKAudioProcessingManager.m @@ -61,15 +61,22 @@ - (void)clearProcessors { // TODO } +- (BOOL)requireAudioDeviceModule:(NSError * _Nullable * _Nullable)error { + if (self.audioDeviceModule != nil) { + return YES; + } + if (error != nil) { + *error = [NSError errorWithDomain:LKAudioProcessingManagerErrorDomain + code:-1 + userInfo:@{ + NSLocalizedDescriptionKey : @"Audio device module is unavailable", + }]; + } + return NO; +} + - (BOOL)startLocalRecording:(NSError * _Nullable * _Nullable)error { - if (self.audioDeviceModule == nil) { - if (error != nil) { - *error = [NSError errorWithDomain:LKAudioProcessingManagerErrorDomain - code:-1 - userInfo:@{ - NSLocalizedDescriptionKey : @"Audio device module is unavailable", - }]; - } + if (![self requireAudioDeviceModule:error]) { return NO; } @@ -97,14 +104,7 @@ - (BOOL)startLocalRecording:(NSError * _Nullable * _Nullable)error { } - (BOOL)stopLocalRecording:(NSError * _Nullable * _Nullable)error { - if (self.audioDeviceModule == nil) { - if (error != nil) { - *error = [NSError errorWithDomain:LKAudioProcessingManagerErrorDomain - code:-1 - userInfo:@{ - NSLocalizedDescriptionKey : @"Audio device module is unavailable", - }]; - } + if (![self requireAudioDeviceModule:error]) { return NO; } diff --git a/ios/LiveKitReactNativeModule.swift b/ios/LiveKitReactNativeModule.swift index 5a778645..e6fae02e 100644 --- a/ios/LiveKitReactNativeModule.swift +++ b/ios/LiveKitReactNativeModule.swift @@ -104,6 +104,8 @@ public class LivekitReactNativeModule: RCTEventEmitter { } } + private static let kModuleErrorDomain = "LivekitReactNativeModule" + private func syncAudioDeviceModule() -> Bool { guard let webRTCModule = self.bridge.module(for: WebRTCModule.self) as? WebRTCModule else { return false @@ -114,44 +116,45 @@ public class LivekitReactNativeModule: RCTEventEmitter { return true } + private func sendRecordingStateEvent(_ stage: String, error: Error? = nil) { + var body: [String: Any] = [ + "stage": stage, + "timestampMs": Int(Date().timeIntervalSince1970 * 1000), + ] + if let error = error { + body["error"] = error.localizedDescription + } + self.sendEvent(withName: LKEvents.kEventAudioRecordingState, body: body) + } + + private func bridgeUnavailableError(context: String) -> NSError { + NSError( + domain: Self.kModuleErrorDomain, + code: -1, + userInfo: [ + NSLocalizedDescriptionKey: + "WebRTCModule is unavailable while \(context) local recording", + ] + ) + } + @objc(startLocalRecording:withRejecter:) public func startLocalRecording(resolve: RCTPromiseResolveBlock, reject: RCTPromiseRejectBlock) { - self.sendEvent(withName: LKEvents.kEventAudioRecordingState, body: [ - "stage": "local_recording_start_requested", - "timestampMs": Int(Date().timeIntervalSince1970 * 1000), - ]) + sendRecordingStateEvent("local_recording_start_requested") guard syncAudioDeviceModule() else { - let bridgeError = NSError( - domain: "LivekitReactNativeModule", - code: -1, - userInfo: [ - NSLocalizedDescriptionKey: - "WebRTCModule is unavailable while starting local recording", - ] - ) - self.sendEvent(withName: LKEvents.kEventAudioRecordingState, body: [ - "stage": "local_recording_start_failed", - "timestampMs": Int(Date().timeIntervalSince1970 * 1000), - "error": bridgeError.localizedDescription, - ]) - reject("startLocalRecording", bridgeError.localizedDescription, bridgeError) + let err = bridgeUnavailableError(context: "starting") + sendRecordingStateEvent("local_recording_start_failed", error: err) + reject("startLocalRecording", err.localizedDescription, err) return } do { try LKAudioProcessingManager.sharedInstance().startLocalRecording() - self.sendEvent(withName: LKEvents.kEventAudioRecordingState, body: [ - "stage": "local_recording_started", - "timestampMs": Int(Date().timeIntervalSince1970 * 1000), - ]) + sendRecordingStateEvent("local_recording_started") resolve(nil) } catch { - self.sendEvent(withName: LKEvents.kEventAudioRecordingState, body: [ - "stage": "local_recording_start_failed", - "timestampMs": Int(Date().timeIntervalSince1970 * 1000), - "error": error.localizedDescription, - ]) + sendRecordingStateEvent("local_recording_start_failed", error: error) reject( "startLocalRecording", "Error starting local recording: \(error.localizedDescription)", @@ -162,27 +165,21 @@ public class LivekitReactNativeModule: RCTEventEmitter { @objc(stopLocalRecording:withRejecter:) public func stopLocalRecording(resolve: RCTPromiseResolveBlock, reject: RCTPromiseRejectBlock) { + sendRecordingStateEvent("local_recording_stop_requested") + guard syncAudioDeviceModule() else { - let bridgeError = NSError( - domain: "LivekitReactNativeModule", - code: -1, - userInfo: [ - NSLocalizedDescriptionKey: - "WebRTCModule is unavailable while stopping local recording", - ] - ) - reject("stopLocalRecording", bridgeError.localizedDescription, bridgeError) + let err = bridgeUnavailableError(context: "stopping") + sendRecordingStateEvent("local_recording_stop_failed", error: err) + reject("stopLocalRecording", err.localizedDescription, err) return } do { try LKAudioProcessingManager.sharedInstance().stopLocalRecording() - self.sendEvent(withName: LKEvents.kEventAudioRecordingState, body: [ - "stage": "local_recording_stopped", - "timestampMs": Int(Date().timeIntervalSince1970 * 1000), - ]) + sendRecordingStateEvent("local_recording_stopped") resolve(nil) } catch { + sendRecordingStateEvent("local_recording_stop_failed", error: error) reject( "stopLocalRecording", "Error stopping local recording: \(error.localizedDescription)",