Skip to content

Commit 4132308

Browse files
santhoshvaigreenfrvrjdimovskaoliverlaz
authored
fix: sync callkit audio config with webrtc config (#2095)
## Overview When CallKit activates the audio session and we must forward this via `audioSessionDidActivate`, WebRTC: - Increments its internal activation count - Sets `isActive = YES` - Clears any interruption state - Notifies delegates that audio can resume This ensures WebRTC knows the session is already active and won't try to activate it again. In this PR, we ensure that callkit audio config matches with our intended webrtc audio config. To avoid clashes and that webrtc audio unit always starts/resumes. ### Why this matters: 1. **WebRTC stores its own configuration preferences** in `RTCAudioSessionConfiguration.webRTC()` 2. When WebRTC later needs to reconfigure audio (e.g., after interruptions), it uses these stored settings 3. If you only configure `AVAudioSession` directly without updating WebRTC's stored config, there could be mismatches during audio route changes or interruptions <!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Summary by CodeRabbit * **Chores** * Added WebRTC dependency to improve audio session management across the platform. * Enhanced audio configuration handling to align with industry standards. * Simplified internal audio session lifecycle event handling for improved stability. * Updated package dependencies to support latest WebRTC integration. <sub>✏️ Tip: You can customize this high-level summary in your review settings.</sub> <!-- end of auto-generated comment: release notes by coderabbit.ai --> --------- Co-authored-by: Artem Grintsevich <greenfrvr@gmail.com> Co-authored-by: jdimovska <jona.dimovska@hotmail.com> Co-authored-by: Oliver Lazoroski <oliver.lazoroski@gmail.com>
1 parent b1e88b6 commit 4132308

17 files changed

Lines changed: 285 additions & 147 deletions

File tree

.github/actions/rn-bootstrap/action.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,7 @@ runs:
3333
3434
- uses: ruby/setup-ruby@v1
3535
with:
36-
ruby-version: 3.1
36+
ruby-version: 3.4
3737
working-directory: sample-apps/react-native/dogfood
3838
bundler-cache: true
3939

packages/client/src/Call.ts

Lines changed: 7 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -664,7 +664,9 @@ export class Call {
664664
this.cancelAutoDrop();
665665
this.clientStore.unregisterCall(this);
666666

667-
globalThis.streamRNVideoSDK?.callManager.stop();
667+
globalThis.streamRNVideoSDK?.callManager.stop({
668+
isRingingTypeCall: this.ringing,
669+
});
668670

669671
this.camera.dispose();
670672
this.microphone.dispose();
@@ -1117,7 +1119,9 @@ export class Call {
11171119
// re-apply them on later reconnections or server-side data fetches
11181120
if (!this.deviceSettingsAppliedOnce && this.state.settings) {
11191121
await this.applyDeviceConfig(this.state.settings, true);
1120-
globalThis.streamRNVideoSDK?.callManager.start();
1122+
globalThis.streamRNVideoSDK?.callManager.start({
1123+
isRingingTypeCall: this.ringing,
1124+
});
11211125
this.deviceSettingsAppliedOnce = true;
11221126
}
11231127

@@ -2694,9 +2698,7 @@ export class Call {
26942698
settings: CallSettingsResponse,
26952699
publish: boolean,
26962700
) => {
2697-
globalThis.streamRNVideoSDK?.callManager.setup({
2698-
default_device: settings.audio.default_device,
2699-
});
2701+
this.speaker.apply(settings);
27002702
await this.camera.apply(settings.video, publish).catch((err) => {
27012703
this.logger.warn('Camera init failed', err);
27022704
});

packages/client/src/devices/SpeakerManager.ts

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,19 +3,60 @@ import { Call } from '../Call';
33
import { isReactNative } from '../helpers/platforms';
44
import { SpeakerState } from './SpeakerState';
55
import { deviceIds$, getAudioOutputDevices } from './devices';
6+
import {
7+
CallSettingsResponse,
8+
AudioSettingsRequestDefaultDeviceEnum,
9+
} from '../gen/coordinator';
610

711
export class SpeakerManager {
812
readonly state: SpeakerState;
913
private subscriptions: Subscription[] = [];
1014
private areSubscriptionsSetUp = false;
1115
private readonly call: Call;
16+
private defaultDevice?: AudioSettingsRequestDefaultDeviceEnum;
1217

1318
constructor(call: Call) {
1419
this.call = call;
1520
this.state = new SpeakerState(call.tracer);
1621
this.setup();
1722
}
1823

24+
apply(settings: CallSettingsResponse) {
25+
if (!isReactNative()) {
26+
return;
27+
}
28+
/// Determines if the speaker should be enabled based on a priority hierarchy of
29+
/// settings.
30+
///
31+
/// The priority order is as follows:
32+
/// 1. If video camera is set to be on by default, speaker is enabled
33+
/// 2. If audio speaker is set to be on by default, speaker is enabled
34+
/// 3. If the default audio device is set to speaker, speaker is enabled
35+
///
36+
/// This ensures that the speaker state aligns with the most important user
37+
/// preference or system requirement.
38+
const speakerOnWithSettingsPriority =
39+
settings.video.camera_default_on ||
40+
settings.audio.speaker_default_on ||
41+
settings.audio.default_device ===
42+
AudioSettingsRequestDefaultDeviceEnum.SPEAKER;
43+
44+
const defaultDevice = speakerOnWithSettingsPriority
45+
? AudioSettingsRequestDefaultDeviceEnum.SPEAKER
46+
: AudioSettingsRequestDefaultDeviceEnum.EARPIECE;
47+
48+
if (this.defaultDevice !== defaultDevice) {
49+
this.call.logger.debug('SpeakerManager: setting default device', {
50+
defaultDevice,
51+
});
52+
this.defaultDevice = defaultDevice;
53+
globalThis.streamRNVideoSDK?.callManager.setup({
54+
defaultDevice,
55+
isRingingTypeCall: this.call.ringing,
56+
});
57+
}
58+
}
59+
1960
setup() {
2061
if (this.areSubscriptionsSetUp) {
2162
return;

packages/client/src/types.ts

Lines changed: 16 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -345,26 +345,36 @@ export type StartCallRecordingFnType = {
345345
): Promise<StartRecordingResponse>;
346346
};
347347

348+
type StreamRNVideoSDKCallManagerRingingParams = {
349+
isRingingTypeCall: boolean;
350+
};
351+
352+
type StreamRNVideoSDKCallManagerSetupParams =
353+
StreamRNVideoSDKCallManagerRingingParams & {
354+
defaultDevice: AudioSettingsRequestDefaultDeviceEnum;
355+
};
356+
348357
export type StreamRNVideoSDKGlobals = {
349358
callManager: {
350359
/**
351360
* Sets up the in call manager.
352361
*/
353362
setup({
354-
default_device,
355-
}: {
356-
default_device: AudioSettingsRequestDefaultDeviceEnum;
357-
}): void;
363+
defaultDevice,
364+
isRingingTypeCall,
365+
}: StreamRNVideoSDKCallManagerSetupParams): void;
358366

359367
/**
360368
* Starts the in call manager.
361369
*/
362-
start(): void;
370+
start({
371+
isRingingTypeCall,
372+
}: StreamRNVideoSDKCallManagerRingingParams): void;
363373

364374
/**
365375
* Stops the in call manager.
366376
*/
367-
stop(): void;
377+
stop({ isRingingTypeCall }: StreamRNVideoSDKCallManagerRingingParams): void;
368378
};
369379
};
370380

packages/react-native-callingx/Callingx.podspec

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,5 +17,7 @@ Pod::Spec.new do |s|
1717
s.public_header_files = "ios/CallingxPublic.h"
1818
s.swift_version = "5.0"
1919

20+
s.dependency "stream-react-native-webrtc"
21+
2022
install_modules_dependencies(s)
2123
end

packages/react-native-callingx/ios/AudioSessionManager.swift

Lines changed: 19 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import Foundation
22
import AVFoundation
3+
import stream_react_native_webrtc
34

45
@objcMembers public class AudioSessionManager: NSObject {
56

@@ -8,36 +9,34 @@ import AVFoundation
89
print("[Callingx][createAudioSessionIfNeeded] Creating audio session")
910
#endif
1011

11-
var categoryOptions: AVAudioSession.CategoryOptions
12+
let categoryOptions: AVAudioSession.CategoryOptions
1213
#if compiler(>=6.2) // For Xcode 26.0+
1314
categoryOptions = [.allowBluetoothHFP, .defaultToSpeaker]
1415
#else
1516
categoryOptions = [.allowBluetooth, .defaultToSpeaker]
1617
#endif
17-
var mode: AVAudioSession.Mode = .videoChat
18+
let mode: AVAudioSession.Mode = .videoChat
1819

19-
let settings = Settings.getSettings()
20-
21-
if let audioSessionSettings = settings["audioSession"] as? [String: Any] {
22-
if let options = audioSessionSettings["categoryOptions"] as? UInt {
23-
categoryOptions = AVAudioSession.CategoryOptions(rawValue: options)
24-
}
20+
// Configure RTCAudioSessionConfiguration to match our intended settings
21+
// This ensures WebRTC's internal state stays consistent during interruptions/route changes
22+
let rtcConfig = RTCAudioSessionConfiguration.webRTC()
23+
rtcConfig.category = AVAudioSession.Category.playAndRecord.rawValue
24+
rtcConfig.mode = mode.rawValue
25+
rtcConfig.categoryOptions = categoryOptions
26+
RTCAudioSessionConfiguration.setWebRTC(rtcConfig)
2527

26-
if let modeString = audioSessionSettings["mode"] as? String {
27-
mode = AVAudioSession.Mode(rawValue: modeString) ?? .default
28-
}
29-
}
28+
// Apply settings via RTCAudioSession (with lock) to keep WebRTC internal state consistent
29+
let rtcSession = RTCAudioSession.sharedInstance()
30+
rtcSession.lockForConfiguration()
31+
defer { rtcSession.unlockForConfiguration() }
3032

31-
let audioSession = AVAudioSession.sharedInstance()
3233
do {
33-
try audioSession.setCategory(.playAndRecord, options: categoryOptions)
34-
try audioSession.setMode(mode)
35-
36-
let sampleRate: Double = 44100.0
37-
try? audioSession.setPreferredSampleRate(sampleRate)
34+
try rtcSession.setCategory(.playAndRecord, mode: mode, options: categoryOptions)
3835

39-
let bufferDuration: TimeInterval = 0.005
40-
try? audioSession.setPreferredIOBufferDuration(bufferDuration)
36+
// Apply sample rate and IO buffer duration from WebRTC's config (source of truth)
37+
// This keeps CallKit setup aligned with WebRTC's intended tuning (e.g. 48kHz and ~20ms by default)
38+
try rtcSession.setPreferredSampleRate(rtcConfig.sampleRate)
39+
try rtcSession.setPreferredIOBufferDuration(rtcConfig.ioBufferDuration)
4140
} catch {
4241
#if DEBUG
4342
print("[Callingx][createAudioSessionIfNeeded] Error configuring audio session: \(error)")

packages/react-native-callingx/ios/CallingxImpl.swift

Lines changed: 22 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
import Foundation
22
import CallKit
33
import AVFoundation
4+
import UIKit
5+
import stream_react_native_webrtc
46

57
// MARK: - Event Names
68
@objcMembers public class CallingxEvents: NSObject {
@@ -695,17 +697,33 @@ import AVFoundation
695697

696698
public func provider(_ provider: CXProvider, didActivate audioSession: AVAudioSession) {
697699
#if DEBUG
698-
print("[Callingx][CXProviderDelegate][provider:didActivateAudioSession]")
700+
print("[Callingx][CXProviderDelegate][provider:didActivateAudioSession] category=\(audioSession.category) mode=\(audioSession.mode)")
699701
#endif
700-
701-
702+
703+
// When CallKit activates the AVAudioSession, inform WebRTC as well.
704+
RTCAudioSession.sharedInstance().audioSessionDidActivate(audioSession)
705+
706+
// Enable wake lock to keep the device awake during the call
707+
DispatchQueue.main.async {
708+
UIApplication.shared.isIdleTimerDisabled = true
709+
}
710+
702711
sendEvent(CallingxEvents.didActivateAudioSession, body: nil)
703712
}
704713

705714
public func provider(_ provider: CXProvider, didDeactivate audioSession: AVAudioSession) {
706715
#if DEBUG
707-
print("[Callingx][CXProviderDelegate][provider:didDeactivateAudioSession]")
716+
print("[Callingx][CXProviderDelegate][provider:didDeactivateAudioSession] category=\(audioSession.category) mode=\(audioSession.mode)")
708717
#endif
718+
719+
// When CallKit deactivates the AVAudioSession, inform WebRTC as well.
720+
RTCAudioSession.sharedInstance().audioSessionDidDeactivate(audioSession)
721+
722+
// Disable wake lock when the call ends
723+
DispatchQueue.main.async {
724+
UIApplication.shared.isIdleTimerDisabled = false
725+
}
726+
709727
sendEvent(CallingxEvents.didDeactivateAudioSession, body: nil)
710728
}
711729

packages/react-native-callingx/package.json

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,7 @@
5858
"devDependencies": {
5959
"@react-native-community/cli": "20.0.1",
6060
"@react-native/babel-preset": "^0.81.5",
61+
"@stream-io/react-native-webrtc": "137.1.0",
6162
"@types/react": "^19.1.0",
6263
"del-cli": "^6.0.0",
6364
"react": "19.1.0",
@@ -66,6 +67,7 @@
6667
"typescript": "^5.9.2"
6768
},
6869
"peerDependencies": {
70+
"@stream-io/react-native-webrtc": ">=137.1.0",
6971
"react": "*",
7072
"react-native": "*"
7173
},

packages/react-native-sdk/ios/StreamInCallManager.swift

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -132,6 +132,7 @@ class StreamInCallManager: RCTEventEmitter {
132132
rtcConfig.category = intendedCategory.rawValue
133133
rtcConfig.mode = intendedMode.rawValue
134134
rtcConfig.categoryOptions = intendedOptions
135+
// This ensures WebRTC's internal state stays consistent during interruptions/route changes
135136
RTCAudioSessionConfiguration.setWebRTC(rtcConfig)
136137

137138
let session = RTCAudioSession.sharedInstance()
@@ -141,6 +142,9 @@ class StreamInCallManager: RCTEventEmitter {
141142
}
142143
do {
143144
try session.setCategory(intendedCategory, mode: intendedMode, options: intendedOptions)
145+
// Apply sample rate and IO buffer duration from WebRTC's config
146+
try session.setPreferredSampleRate(rtcConfig.sampleRate)
147+
try session.setPreferredIOBufferDuration(rtcConfig.ioBufferDuration)
144148
if (wasRecording) {
145149
try adm.setRecording(wasRecording)
146150
}

packages/react-native-sdk/src/modules/call-manager/CallManager.ts

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,10 @@
11
import { NativeEventEmitter, NativeModules, Platform } from 'react-native';
22
import { AudioDeviceStatus, StreamInCallManagerConfig } from './types';
3+
import { getCallingxLibIfAvailable } from '../../utils/push/libs/callingx';
4+
import { videoLoggerSystem } from '@stream-io/video-client';
35

46
const NativeManager = NativeModules.StreamInCallManager;
7+
const CallingxModule = getCallingxLibIfAvailable();
58

69
const invariant = (condition: boolean, message: string) => {
710
if (!condition) throw new Error(message);
@@ -72,6 +75,19 @@ class SpeakerManager {
7275
};
7376
}
7477

78+
const shouldBypassForCallKit = (): boolean => {
79+
if (Platform.OS !== 'ios') {
80+
return false;
81+
}
82+
if (!CallingxModule) {
83+
return false;
84+
}
85+
return (
86+
CallingxModule.isSetup &&
87+
(CallingxModule.hasRegisteredCall() || CallingxModule.isOngoingCallsEnabled)
88+
);
89+
};
90+
7591
export class CallManager {
7692
android = new AndroidCallManager();
7793
ios = new IOSCallManager();
@@ -95,6 +111,14 @@ export class CallManager {
95111
* @param config.enableStereoAudioOutput Whether to enable stereo audio output. Only supported for listener audio role.
96112
*/
97113
start = (config?: StreamInCallManagerConfig): void => {
114+
if (shouldBypassForCallKit()) {
115+
videoLoggerSystem
116+
.getLogger('CallManager')
117+
.debug(
118+
'start: skipping start as callkit is handling the audio session',
119+
);
120+
return;
121+
}
98122
NativeManager.setAudioRole(config?.audioRole ?? 'communicator');
99123
if (config?.audioRole === 'communicator') {
100124
const type = config.deviceEndpointType ?? 'speaker';
@@ -110,6 +134,12 @@ export class CallManager {
110134
* Stops the in call manager.
111135
*/
112136
stop = (): void => {
137+
if (shouldBypassForCallKit()) {
138+
videoLoggerSystem
139+
.getLogger('CallManager')
140+
.debug('stop: skipping stop as callkit is handling the audio session');
141+
return;
142+
}
113143
NativeManager.stop();
114144
};
115145

0 commit comments

Comments
 (0)