Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions ios/Headers/LKAudioProcessingManager.h
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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
69 changes: 69 additions & 0 deletions ios/LKAudioProcessingManager.m
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
#import "LKAudioProcessingManager.h"
#import "LKAudioProcessingAdapter.h"

static NSString *const LKAudioProcessingManagerErrorDomain = @"LKAudioProcessingManagerErrorDomain";

@implementation LKAudioProcessingManager

+ (instancetype)sharedInstance {
Expand Down Expand Up @@ -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
86 changes: 86 additions & 0 deletions ios/LiveKitReactNativeModule.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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, *) {
Expand Down Expand Up @@ -254,6 +339,7 @@ public class LivekitReactNativeModule: RCTEventEmitter {
LKEvents.kEventVolumeProcessed,
LKEvents.kEventMultibandProcessed,
LKEvents.kEventAudioData,
LKEvents.kEventAudioRecordingState,
]
}
}
4 changes: 4 additions & 0 deletions ios/LivekitReactNativeModule.m
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down
5 changes: 0 additions & 5 deletions ios/audio/AudioSinkRenderer.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
49 changes: 49 additions & 0 deletions src/audio/AudioSession.test.ts
Original file line number Diff line number Diff line change
@@ -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();
});
});
17 changes: 17 additions & 0 deletions src/audio/AudioSession.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
2 changes: 2 additions & 0 deletions src/events/EventEmitter.ts
Original file line number Diff line number Diff line change
@@ -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';

Expand All @@ -11,6 +12,7 @@ const NATIVE_EVENTS = [
'LK_VOLUME_PROCESSED',
'LK_MULTIBAND_PROCESSED',
'LK_AUDIO_DATA',
'LK_AUDIO_RECORDING_STATE',
];

const eventEmitter = new EventEmitter();
Expand Down
Loading