Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 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
6 changes: 5 additions & 1 deletion ios/AudioUtils.swift
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,11 @@ public class AudioUtils {
case "default_":
.default
case "voicePrompt":
.voicePrompt
if #available(iOS 12.0, *) {
.voicePrompt
} else {
.default
}
case "videoRecording":
.videoRecording
case "videoChat":
Expand Down
45 changes: 29 additions & 16 deletions ios/LiveKitReactNativeModule.swift
Original file line number Diff line number Diff line change
Expand Up @@ -117,23 +117,26 @@ public class LivekitReactNativeModule: RCTEventEmitter {

resolve(nil)
}
@objc(setAppleAudioConfiguration:)
public func setAppleAudioConfiguration(_ configuration: NSDictionary) {

@objc(setAppleAudioConfiguration:withResolver:withRejecter:)
public func setAppleAudioConfiguration(_ configuration: NSDictionary, resolve: RCTPromiseResolveBlock, reject: RCTPromiseRejectBlock) {
let session = RTCAudioSession.sharedInstance()
let config = RTCAudioSessionConfiguration.webRTC()

let appleAudioCategory = configuration["audioCategory"] as? String
let appleAudioCategoryOptions = configuration["audioCategoryOptions"] as? [String]
let appleAudioMode = configuration["audioMode"] as? String

session.lockForConfiguration()

defer {
session.unlockForConfiguration()
}

var categoryChanged = false

if let appleAudioCategoryOptions = appleAudioCategoryOptions {
categoryChanged = true

var newOptions: AVAudioSession.CategoryOptions = []
for option in appleAudioCategoryOptions {
if option == "mixWithOthers" {
Expand All @@ -152,33 +155,43 @@ public class LivekitReactNativeModule: RCTEventEmitter {
}
config.categoryOptions = newOptions
}

if let appleAudioCategory = appleAudioCategory {
categoryChanged = true
config.category = AudioUtils.audioSessionCategoryFromString(appleAudioCategory).rawValue
}

if categoryChanged {
do {
try session.setCategory(AVAudioSession.Category(rawValue: config.category), with: config.categoryOptions)
} catch {
NSLog("Error setting category: %@", error.localizedDescription)
reject("setAppleAudioConfiguration", "Error setting category: \(error.localizedDescription)", error)
return
}
}

if let appleAudioMode = appleAudioMode {
let mode = AudioUtils.audioSessionModeFromString(appleAudioMode)
config.mode = mode.rawValue
do {
try session.setMode(mode)
} catch {
NSLog("Error setting mode: %@", error.localizedDescription)
reject("setAppleAudioConfiguration", "Error setting mode: \(error.localizedDescription)", error)
return
}
}

session.unlockForConfiguration()

// Activate the audio session
do {
try session.setActive(true)
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Does the session need to be activated here? The intention of this method was just for changing the configuration that the webrtc AVAudioSession will use, not necessarily activate it (there's also no corresponding way to deactivate it).

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Maybe at startAudioSession() is better ?

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I actually don't see a WebRTC startAudioSession() API, maybe it is our internal one ?

From a quick look at WebRTC RTCAudioSession::setActive API, it seems to handle the cases that when configuration gets changed, it will be called to apply the changes. From a quick google search, which gave out :
Yes, you must call setActive(true) after setting the AVAudioSession category for the new configuration to take effect. Setting the category defines your app's intended audio behavior, but activating the session enforces that behavior within the system.

I believe what it meant is changing a configuration of inactive audio stream, then you need to call setActive to apply the change. If the audio stream is active already, it shouldn't be needed. But WebRTC code seems to handle both use cases already:

- (BOOL)setActive:(BOOL)active error:(NSError **)outError {
  if (![self checkLock:outError]) {
    return NO;
  }
  int activationCount = _activationCount.load();
  if (!active && activationCount == 0) {
    RTCLogWarning(@"Attempting to deactivate without prior activation.");
  }
  [self notifyWillSetActive:active];
  BOOL success = YES;
  BOOL isActive = self.isActive;
  // Keep a local error so we can log it.
  NSError *error = nil;
  BOOL shouldSetActive =
      (active && !isActive) || (!active && isActive && activationCount == 1);
  // Attempt to activate if we're not active.
  // Attempt to deactivate if we're active and it's the last unbalanced call.
  if (shouldSetActive) {
    AVAudioSession *session = self.session;
    // AVAudioSessionSetActiveOptionNotifyOthersOnDeactivation is used to ensure
    // that other audio sessions that were interrupted by our session can return
    // to their active state. It is recommended for VoIP apps to use this
    // option.
    AVAudioSessionSetActiveOptions options =
        active ? 0 : AVAudioSessionSetActiveOptionNotifyOthersOnDeactivation;
    success = [session setActive:active withOptions:options error:&error];
    if (outError) {
      *outError = error;
    }
  } 
  if (success) {
    if (active) {
      if (shouldSetActive) {
        self.isActive = active;
        if (self.isInterrupted) {
          self.isInterrupted = NO;
          [self notifyDidEndInterruptionWithShouldResumeSession:YES];
        }
      }
      [self incrementActivationCount];
      [self notifyDidSetActive:active];
    }
  } else {
    RTCLogError(@"Failed to setActive:%d. Error: %@",
                active,
                error.localizedDescription); 
    [self notifyFailedToSetActive:active error:error];
  }
  // Set isActive and decrement activation count on deactivation
  // whether or not it succeeded.
  if (!active) {
    if (shouldSetActive) {
      self.isActive = active;
      [self notifyDidSetActive:active];
    }
    [self decrementActivationCount];
  }
  RTCLog(@"Number of current activations: %d", _activationCount.load());
  return success;
}`

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The AVAudioSession automatically gets activated when the WebRTC call gets started:

https://github.com/webrtc-sdk/webrtc/blob/ebd5a9f966b31aaa723ac7d2520e56d7ae9101f7/sdk/objc/native/src/audio/audio_device_ios.mm#L943

https://github.com/webrtc-sdk/webrtc/blob/ebd5a9f966b31aaa723ac7d2520e56d7ae9101f7/sdk/objc/components/audio/RTCAudioSession.mm#L749

It's more that the intention for this API is just to set the configuration that will be used during the call. Users can either preset the configuration prior to the call, or change it during the call as needed, but I don't think we'd want to activate the audiosession while there's no active call (as we'd be leaking our reach beyond the scope of our SDK and potentially clash with any other audio handling from the user).

Copy link
Member Author

@hiroshihorie hiroshihorie Oct 4, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I removed setActive from setAppleAudioConfiguration().

AudioUnit version of AudioDeviceModule:
I hadn’t noticed that it calls setActive internally 😅. In Swift/Flutter, we always explicitly called setActive. But it doesn’t seem to take into account the fact that setActive can fail when high-priority audio is active. In my opinion, without session activation the audio stack shouldn’t even attempt to start.

AudioEngine version of AudioDeviceModule:
This one doesn’t call setActive automatically. It only performs a category check but doesn’t actually modify anything. That means the SDK side is fully responsible for activation (except for interruption handling).

Side note: I’m not a fan of RTCAudioSession since it introduces extra states, and I see hacky workarounds to support AudioUnit that AVAudioEngine doesn’t require. It can also interfere with users or libraries that interact with AVAudioSession directly. My long-term goal is to avoid touching it at all. Currently, the AVAudioEngine ADM only uses it for interruption observation.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Looks good, users should be calling our start/stop audio session apis anyways.

} catch {
reject("setAppleAudioConfiguration", "Error activating audio session: \(error.localizedDescription)", error)
return
}

resolve(nil)
}

@objc(createAudioSinkListener:trackId:)
public func createAudioSinkListener(_ pcId: NSNumber, trackId: String) -> String {
let renderer = AudioSinkRenderer(eventEmitter: self)
Expand Down
4 changes: 3 additions & 1 deletion ios/LivekitReactNativeModule.m
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,9 @@ @interface RCT_EXTERN_MODULE(LivekitReactNativeModule, RCTEventEmitter)


/// Configure audio config for WebRTC
RCT_EXTERN_METHOD(setAppleAudioConfiguration:(NSDictionary *) configuration)
RCT_EXTERN_METHOD(setAppleAudioConfiguration:(NSDictionary *)configuration
withResolver:(RCTPromiseResolveBlock)resolve
withRejecter:(RCTPromiseRejectBlock)reject)

RCT_EXTERN__BLOCKING_SYNCHRONOUS_METHOD(createAudioSinkListener:(nonnull NSNumber *)pcId
trackId:(nonnull NSString *)trackId)
Expand Down
25 changes: 23 additions & 2 deletions src/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -24,14 +24,34 @@
import { setupNativeEvents } from './events/EventEmitter';
import { ReadableStream, WritableStream } from 'web-streams-polyfill';

export interface RegisterGlobalsOptions {
/**
* Automatically configure audio session before accessing microphone.
* When enabled, sets the iOS audio category to 'playAndRecord' before getUserMedia.
*
* @default true
* @platform ios
*/
autoConfigureAudioSession?: boolean;
}

/**
* Registers the required globals needed for LiveKit to work.
*
* Must be called before using LiveKit.
*
* @param options Optional configuration for global registration
*/
export function registerGlobals() {
export function registerGlobals(options?: RegisterGlobalsOptions) {
const opts = {
autoConfigureAudioSession: true,
...options,
};

webrtcRegisterGlobals();
iosCategoryEnforce();
if (opts.autoConfigureAudioSession) {
iosCategoryEnforce();
}
livekitRegisterGlobals();
setupURLPolyfill();
fixWebrtcAdapter();
Expand Down Expand Up @@ -161,4 +181,5 @@
LogLevel,
SetLogLevelOptions,
RNKeyProviderOptions,
RegisterGlobalsOptions,

Check failure on line 184 in src/index.tsx

View workflow job for this annotation

GitHub Actions / test

Export declaration conflicts with exported declaration of 'RegisterGlobalsOptions'.
};
Loading