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..e95db0b4 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)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 requireAudioDeviceModule:error]) { + 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 requireAudioDeviceModule:error]) { + 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 b3c3ef47..e6fae02e 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 kEventAudioRecordingState = "LK_AUDIO_RECORDING_STATE"; } @objc(LivekitReactNativeModule) @@ -103,6 +104,90 @@ 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 + } + + LKAudioProcessingManager.sharedInstance().audioDeviceModule = + webRTCModule.peerConnectionFactory.audioDeviceModule + 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) { + sendRecordingStateEvent("local_recording_start_requested") + + guard syncAudioDeviceModule() else { + let err = bridgeUnavailableError(context: "starting") + sendRecordingStateEvent("local_recording_start_failed", error: err) + reject("startLocalRecording", err.localizedDescription, err) + return + } + + do { + try LKAudioProcessingManager.sharedInstance().startLocalRecording() + sendRecordingStateEvent("local_recording_started") + resolve(nil) + } catch { + sendRecordingStateEvent("local_recording_start_failed", error: error) + reject( + "startLocalRecording", + "Error starting local recording: \(error.localizedDescription)", + error + ) + } + } + + @objc(stopLocalRecording:withRejecter:) + public func stopLocalRecording(resolve: RCTPromiseResolveBlock, reject: RCTPromiseRejectBlock) { + sendRecordingStateEvent("local_recording_stop_requested") + + guard syncAudioDeviceModule() else { + let err = bridgeUnavailableError(context: "stopping") + sendRecordingStateEvent("local_recording_stop_failed", error: err) + reject("stopLocalRecording", err.localizedDescription, err) + return + } + + do { + try LKAudioProcessingManager.sharedInstance().stopLocalRecording() + sendRecordingStateEvent("local_recording_stopped") + resolve(nil) + } catch { + sendRecordingStateEvent("local_recording_stop_failed", error: error) + reject( + "stopLocalRecording", + "Error stopping local recording: \(error.localizedDescription)", + error + ) + } + } + @objc(showAudioRoutePicker) public func showAudioRoutePicker() { if #available(iOS 11.0, *) { @@ -254,6 +339,7 @@ public class LivekitReactNativeModule: RCTEventEmitter { LKEvents.kEventVolumeProcessed, LKEvents.kEventMultibandProcessed, LKEvents.kEventAudioData, + 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/ios/audio/AudioSinkRenderer.swift b/ios/audio/AudioSinkRenderer.swift index e3dbc65d..c5d99c6a 100644 --- a/ios/audio/AudioSinkRenderer.swift +++ b/ios/audio/AudioSinkRenderer.swift @@ -24,11 +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() - NSLog("AUDIO DATA!!!!") - NSLog("\(data.length)") - NSLog(base64) - NSLog("\(base64.count)") - NSLog("\(length)") eventEmitter.sendEvent(withName: LKEvents.kEventAudioData, body: [ "data": base64, "id": reactTag diff --git a/src/audio/AudioSession.test.ts b/src/audio/AudioSession.test.ts new file mode 100644 index 00000000..c8f1efea --- /dev/null +++ b/src/audio/AudioSession.test.ts @@ -0,0 +1,49 @@ +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(), + 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); + }); + + 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/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/events/EventEmitter.ts b/src/events/EventEmitter.ts index 881cec38..a582d929 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'; @@ -11,6 +12,7 @@ const NATIVE_EVENTS = [ 'LK_VOLUME_PROCESSED', 'LK_MULTIBAND_PROCESSED', 'LK_AUDIO_DATA', + 'LK_AUDIO_RECORDING_STATE', ]; const eventEmitter = new EventEmitter();