Skip to content

Commit c2e0f21

Browse files
authored
feat: hi-fi audio (#2305)
### 💡 Overview **Hi-fi audio** * Public api for stereo output enabling is simplified. `callManager.start({ audioRole: 'listener' })` enables stereo output for both platforms. Should be invoked on pre-join stage. * Wired stereo input enable/disable flag for Android. For now it is omitted. **Presented media engine** * Media engine is a layer responsible for creating/disposing WebRTC peer connection factory in runtime during call join/leave stages. Peer connection factory and audio device module instances are defined on per-call basis. Only one factory instance can be created at a time. Web resolves to a no-op engine. * Guarded join/leave race. There are several implicit factory creators during join flow: `getGenericSdp` and `initPublisherAndSubscriber` (RTCPeerConnection constructor). The guard prevents creating new instance if the join flow was interrupted by leave invocation. **Made RN lobby independent from webrtc** * Presented new video preview component, which uses video capturer directly without creating local tracks. That video capturer instance is passed later to a local track as a source of media * Made device managers mute/unmute on pre-join stage update corresponding state in an optimistic manner. That state is applied during tracks publishing. **Hardened call manager and audio wiring pipeline** * Public call manager now won't invoke audio session configuration explicitly. Instead it stores configuration params, which are applied during join flow. Those params may override default call settings (e.g. deviceEndpointType). Params are disposed during leave stage. * Audio engine interruptions subscription for callingx is now managed during join/leave, instead of single global subscription. 🎫 Ticket: https://linear.app/stream/issue/RN-402/hi-fi-audio 📑 Docs: GetStream/docs-content#1444 Corresponding WebRTC PR: GetStream/react-native-webrtc#50 <!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Summary by CodeRabbit * **New Features** * Added per-call WebRTC media engine support with configurable providers. * Added React Native lobby camera previews with on-demand permissions and optimistic camera state. * Added iOS controls for microphone mute mode and recording preparation. * Added audio-engine subscription management for CallingX and in-call behavior. * **Bug Fixes** * Prevented join/leave race conditions from continuing setup after leaving. * Improved React Native media capture, camera controls, and device-state handling. * Improved iOS audio stability when no active audio device is available. <!-- end of auto-generated comment: release notes by coderabbit.ai -->
1 parent 425b5c7 commit c2e0f21

41 files changed

Lines changed: 1312 additions & 722 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

packages/client/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ export * from './src/stats/types';
1111

1212
export * from './src/Call';
1313
export * from './src/CallType';
14+
export * from './src/rtc/mediaEngine';
1415
export * from './src/StreamVideoClient';
1516
export * from './src/StreamSfuClient';
1617
export * from './src/devices';

packages/client/src/Call.ts

Lines changed: 105 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,9 @@ import { StreamSfuClient } from './StreamSfuClient';
22
import { SfuJoinError } from './errors';
33
import {
44
BasePeerConnectionOpts,
5+
type CallMediaEngine,
56
Dispatcher,
7+
getCallMediaEngineProvider,
68
getGenericSdp,
79
isAudioTrackType,
810
isSfuEvent,
@@ -332,6 +334,8 @@ export class Call {
332334
private allowOwnTracksLoopback = false;
333335
private hasJoinedOnce = false;
334336
private deviceSettingsAppliedOnce = false;
337+
private callManagerStarted = false;
338+
private leaveGeneration = 0;
335339
private credentials?: Credentials;
336340

337341
private initialized = false;
@@ -355,6 +359,14 @@ export class Call {
355359
ClientCapability.SUBSCRIBER_VIDEO_PAUSE,
356360
]);
357361

362+
/**
363+
* The in-flight per-call media engine. On web/React this resolves to a thin
364+
* globals-backed engine (no provider registered); React Native registers a
365+
* provider that owns a per-call native factory.
366+
* @internal
367+
*/
368+
private mediaEnginePromise?: Promise<CallMediaEngine>;
369+
358370
/**
359371
* Constructs a new `Call` instance.
360372
*
@@ -703,6 +715,8 @@ export class Call {
703715
return;
704716
}
705717

718+
this.leaveGeneration += 1;
719+
706720
if (callingState === CallingState.JOINING) {
707721
const waitUntilCallJoined = () => {
708722
return new Promise<void>((resolve) => {
@@ -803,13 +817,15 @@ export class Call {
803817

804818
globalThis.streamRNVideoSDK?.callManager.stop({
805819
isRingingTypeCall: this.ringing,
820+
shouldStopCallManager: this.callManagerStarted,
806821
});
807822

808823
this.camera.dispose();
809824
this.microphone.dispose();
810825
this.screenShare.dispose();
811826
this.speaker.dispose();
812827
this.deviceSettingsAppliedOnce = false;
828+
this.callManagerStarted = false;
813829

814830
const stopOnLeavePromises: Promise<void>[] = [];
815831
if (this.camera.stopOnLeave) {
@@ -822,6 +838,23 @@ export class Call {
822838
stopOnLeavePromises.push(this.screenShare.disable(true));
823839
}
824840
await Promise.all(stopOnLeavePromises);
841+
842+
// Dispose the per-call media engine last — after peer connections and
843+
// local tracks are gone — so the backing factory tears down with no
844+
// owned PCs/tracks. A fresh `join()` builds a new engine.
845+
if (this.mediaEnginePromise) {
846+
const enginePromise = this.mediaEnginePromise;
847+
this.mediaEnginePromise = undefined;
848+
this.logger.debug('Disposing per-call media factory');
849+
await enginePromise
850+
.then((engine) => {
851+
globalThis.streamRNVideoSDK?.callingX?.unwireAudioEngineSubscription();
852+
return engine.dispose();
853+
})
854+
.catch((err) => {
855+
this.logger.warn('Failed to dispose media engine', err);
856+
});
857+
}
825858
});
826859
};
827860

@@ -941,11 +974,7 @@ export class Call {
941974
this.clientStore.registerOrUpdateCall(this);
942975
}
943976
// Skip speaker setup on RN if ringing was requested or the call is already ringing
944-
const skipSpeakerApply = isReactNative()
945-
? params?.ring === true
946-
? true
947-
: this.ringing
948-
: false;
977+
const skipSpeakerApply = isReactNative();
949978
await this.applyDeviceConfig(
950979
response.call.settings,
951980
false,
@@ -982,11 +1011,7 @@ export class Call {
9821011
}
9831012

9841013
// Skip speaker setup on RN if ringing was requested or the call is already ringing
985-
const skipSpeakerApply = isReactNative()
986-
? data?.ring === true
987-
? true
988-
: this.ringing
989-
: false;
1014+
const skipSpeakerApply = isReactNative();
9901015
await this.applyDeviceConfig(
9911016
response.call.settings,
9921017
false,
@@ -1194,12 +1219,26 @@ export class Call {
11941219
private doJoin = async (data?: JoinCallData): Promise<void> => {
11951220
const connectStartTime = Date.now();
11961221
const callingState = this.state.callingState;
1222+
const joinLeaveGeneration = this.leaveGeneration;
1223+
const supersededByLeave = () =>
1224+
this.leaveGeneration !== joinLeaveGeneration;
11971225

11981226
this.joinCallData = data;
11991227

12001228
this.logger.debug('Starting join flow');
12011229
this.state.setCallingState(CallingState.JOINING);
12021230

1231+
// Ensure the per-call media engine exists before any peer connection
1232+
// (codec probe, subscriber, publisher) or capture happens, so the WebRTC
1233+
// globals resolve to the call's factory. Idempotent across
1234+
// reconnect/migration attempts.
1235+
await this.ensureMediaFactory();
1236+
1237+
const callingX = globalThis.streamRNVideoSDK?.callingX;
1238+
if (callingX) {
1239+
callingX.wireAudioEngineSubscription();
1240+
}
1241+
12031242
const performingMigration =
12041243
this.reconnectStrategy === WebsocketReconnectStrategy.MIGRATE;
12051244
const performingRejoin =
@@ -1270,6 +1309,11 @@ export class Call {
12701309
// the capabilities of the client (codec support, etc.)
12711310
const { dangerouslyForceCodec, fmtpLine, subscriberFmtpLine } =
12721311
this.clientPublishOptions || {};
1312+
// skip if a leave superseded this join so codec detection doesn't resolve to a default factory.
1313+
if (supersededByLeave()) {
1314+
this.logger.debug('Join superseded by leave; skipping codec detection');
1315+
return;
1316+
}
12731317
const [subscriberSdp, publisherSdp] = await Promise.all([
12741318
getGenericSdp('recvonly', dangerouslyForceCodec, subscriberFmtpLine),
12751319
getGenericSdp('sendonly', dangerouslyForceCodec, fmtpLine),
@@ -1329,6 +1373,13 @@ export class Call {
13291373
}
13301374
}
13311375

1376+
// If the user left while this join was in flight, bail before re-setting JOINED and before
1377+
// peer-connection setup below (both run synchronously after this, so one check covers them).
1378+
if (supersededByLeave()) {
1379+
this.logger.debug('Join superseded by leave; aborting join flow');
1380+
return;
1381+
}
1382+
13321383
if (!performingMigration) {
13331384
// in MIGRATION, `JOINED` state is set in `this.reconnectMigrate()`
13341385
this.state.setCallingState(CallingState.JOINED);
@@ -1375,12 +1426,21 @@ export class Call {
13751426

13761427
// device settings should be applied only once, we don't have to
13771428
// re-apply them on later reconnections or server-side data fetches
1378-
if (!this.deviceSettingsAppliedOnce && this.state.settings) {
1429+
if (
1430+
!this.deviceSettingsAppliedOnce &&
1431+
this.state.settings &&
1432+
!supersededByLeave()
1433+
) {
13791434
await this.applyDeviceConfig(this.state.settings, true, false);
1435+
this.deviceSettingsAppliedOnce = true;
1436+
}
1437+
1438+
if (!this.callManagerStarted && !supersededByLeave()) {
13801439
globalThis.streamRNVideoSDK?.callManager.start({
13811440
isRingingTypeCall: this.ringing,
1441+
cid: this.cid,
13821442
});
1383-
this.deviceSettingsAppliedOnce = true;
1443+
this.callManagerStarted = true;
13841444
}
13851445

13861446
// We shouldn't persist the `ring` and `notify` state after joining the call
@@ -1669,6 +1729,39 @@ export class Call {
16691729
return joinResponse;
16701730
};
16711731

1732+
/**
1733+
* Whether the per-call media engine currently exists. True from join until leave.
1734+
*
1735+
* @internal an internal getter and should not be used outside the SDK.
1736+
*/
1737+
get hasMediaEngine(): boolean {
1738+
return !!this.mediaEnginePromise;
1739+
}
1740+
1741+
/**
1742+
* Ensures a {@link CallMediaEngine} exists for this call's media session and
1743+
* returns it. Idempotent: the engine is created once via the registered
1744+
* provider (see `setCallMediaEngineProvider`) and cached until `leave()`
1745+
* disposes it. Concurrent callers (e.g. camera + microphone enabling in
1746+
* parallel) share the same engine because the in-flight creation promise is
1747+
* cached, never the unresolved result.
1748+
*
1749+
* @internal
1750+
*/
1751+
ensureMediaFactory = async (): Promise<CallMediaEngine> => {
1752+
if (!this.mediaEnginePromise) {
1753+
const provider = getCallMediaEngineProvider();
1754+
1755+
this.logger.debug(`Requesting per-call media factory creation`);
1756+
this.mediaEnginePromise = Promise.resolve(provider()).catch((err) => {
1757+
// Drop the cached rejection so a retried join() can rebuild the engine
1758+
this.mediaEnginePromise = undefined;
1759+
throw err;
1760+
});
1761+
}
1762+
return this.mediaEnginePromise;
1763+
};
1764+
16721765
/**
16731766
* Handles the closing of the SFU signal connection.
16741767
*

packages/client/src/devices/AudioDeviceManager.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import { DeviceManager } from './DeviceManager';
22
import { AudioDeviceManagerState } from './AudioDeviceManagerState';
33
import { AudioBitrateProfile } from '../gen/video/sfu/models/models';
44
import { TrackPublishOptions } from '../rtc';
5+
import { isReactNative } from '../helpers/platforms';
56

67
/**
78
* Base class for High Fidelity enabled Device Managers.
@@ -17,6 +18,11 @@ export abstract class AudioDeviceManager<
1718
if (!this.call.state.settings?.audio.hifi_audio_enabled) {
1819
throw new Error('High Fidelity audio is not enabled for this call');
1920
}
21+
if (isReactNative() && this.call.hasMediaEngine) {
22+
throw new Error(
23+
'setAudioBitrateProfile must be called before joining the call.',
24+
);
25+
}
2026
this.doSetAudioBitrateProfile(profile);
2127
this.state.setAudioBitrateProfile(profile);
2228
if (this.enabled) {

packages/client/src/devices/CameraManager.ts

Lines changed: 61 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import { VideoSettingsResponse } from '../gen/coordinator';
77
import { TrackType } from '../gen/video/sfu/models/models';
88
import { isMobile } from '../helpers/compatibility';
99
import { isReactNative } from '../helpers/platforms';
10+
import { CallingState } from '../store';
1011
import { DevicePersistenceOptions } from './devicePersistence';
1112

1213
export class CameraManager extends DeviceManager<CameraManagerState> {
@@ -124,6 +125,53 @@ export class CameraManager extends DeviceManager<CameraManagerState> {
124125
}
125126
}
126127

128+
override enable(): Promise<void> {
129+
if (
130+
isReactNative() &&
131+
this.call.state.callingState !== CallingState.JOINED
132+
) {
133+
this.state.setPendingStatus('enabled');
134+
return Promise.resolve();
135+
}
136+
137+
return super.enable();
138+
}
139+
140+
override disable(options: { forceStop?: boolean }): Promise<void>;
141+
override disable(forceStop?: boolean): Promise<void>;
142+
override async disable(
143+
forceStopOrOptions?: boolean | { forceStop?: boolean },
144+
): Promise<void> {
145+
if (
146+
isReactNative() &&
147+
this.call.state.callingState !== CallingState.JOINED
148+
) {
149+
this.state.setPendingStatus('disabled');
150+
return;
151+
}
152+
153+
// forward verbatim to the base, narrowing so the right overload is selected
154+
if (forceStopOrOptions === undefined) return super.disable();
155+
if (typeof forceStopOrOptions === 'boolean') {
156+
return super.disable(forceStopOrOptions);
157+
}
158+
return super.disable(forceStopOrOptions);
159+
}
160+
161+
override toggle(): Promise<void> {
162+
if (
163+
isReactNative() &&
164+
this.call.state.callingState !== CallingState.JOINED
165+
) {
166+
this.state.setPendingStatus(
167+
this.state.optimisticStatus === 'enabled' ? 'disabled' : 'enabled',
168+
);
169+
return Promise.resolve();
170+
}
171+
172+
return super.toggle();
173+
}
174+
127175
/**
128176
* Applies the video settings to the camera.
129177
*
@@ -166,9 +214,15 @@ export class CameraManager extends DeviceManager<CameraManagerState> {
166214
}
167215
}
168216

169-
const { mediaStream } = this.state;
170-
if (canPublish && publish && this.enabled && mediaStream) {
171-
await this.publishStream(mediaStream);
217+
if (isReactNative() && publish && canPublish) {
218+
// On RN the camera is enabled/disabled optimistically before JOINED. Reconcile now
219+
// acquires the track and publishes it, so it fully owns the publish.
220+
await this.reconcileOptimisticStatus();
221+
} else {
222+
const { mediaStream } = this.state;
223+
if (canPublish && publish && this.enabled && mediaStream) {
224+
await this.publishStream(mediaStream);
225+
}
172226
}
173227
}
174228

@@ -196,9 +250,12 @@ export class CameraManager extends DeviceManager<CameraManagerState> {
196250
return constraints;
197251
}
198252

199-
protected override getStream(
253+
protected override async getStream(
200254
constraints: MediaTrackConstraints,
201255
): Promise<MediaStream> {
256+
// Ensure the call's media factory exists before capture so the resulting
257+
// track is owned by it (the WebRTC globals resolve to the live factory).
258+
await this.call.ensureMediaFactory();
202259
return getVideoStream(constraints, this.call.tracer);
203260
}
204261
}

packages/client/src/devices/DeviceManager.ts

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -509,6 +509,23 @@ export abstract class DeviceManager<
509509
}
510510
}
511511

512+
protected reconcileOptimisticStatus = async (): Promise<void> => {
513+
const target = this.state.optimisticStatus;
514+
await withCancellation(this.statusChangeConcurrencyTag, async (signal) => {
515+
try {
516+
if (target === 'enabled' && this.state.status !== 'enabled') {
517+
await this.unmuteStream();
518+
if (!signal.aborted) this.state.setStatus('enabled');
519+
} else if (target === 'disabled' && this.state.status === 'enabled') {
520+
// mirror whatever disable() does to stop/pause the track per disableMode
521+
if (!signal.aborted) this.state.setStatus('disabled');
522+
}
523+
} finally {
524+
if (!signal.aborted) this.state.setPendingStatus(this.state.status);
525+
}
526+
});
527+
};
528+
512529
private disableTracks() {
513530
this.getTracks().forEach((track) => {
514531
if (track.enabled) track.enabled = false;

0 commit comments

Comments
 (0)