Skip to content

Commit 13aa615

Browse files
committed
feat: support iOS 26 Speaker Selection API via shared WebAudio relay element.
1 parent 29ffc1f commit 13aa615

5 files changed

Lines changed: 152 additions & 112 deletions

File tree

src/index.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -53,9 +53,9 @@ import {
5353
isLocalTrack,
5454
isRemoteParticipant,
5555
isRemoteTrack,
56+
isSafariSpeakerSelectionSupported,
5657
isVideoCodec,
5758
isVideoTrack,
58-
isSafariSpeakerSelectionSupported,
5959
supportsAV1,
6060
supportsAdaptiveStream,
6161
supportsAudioOutputSelection,

src/room/Room.ts

Lines changed: 28 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -103,10 +103,12 @@ import {
103103
import {
104104
Future,
105105
createDummyVideoStreamTrack,
106+
disposeSharedRelay,
106107
extractChatMessage,
107108
extractTranscriptionSegments,
108109
getDisconnectReasonFromConnectionError,
109110
getEmptyAudioStreamTrack,
111+
getOrCreateSharedRelay,
110112
isBrowserSupported,
111113
isCloud,
112114
isLocalAudioTrack,
@@ -1473,22 +1475,26 @@ class Room extends (EventEmitter as new () => TypedEmitter<RoomEventCallbacks>)
14731475
this.audioContext?.setSinkId(deviceId);
14741476
}
14751477

1476-
// also set audio output on all audio elements, even if webAudioMix is enabled in order to workaround echo cancellation not working on chrome with non-default output devices
1477-
// see https://issues.chromium.org/issues/40252911#comment7
1478-
await Promise.all(
1479-
Array.from(this.remoteParticipants.values()).map((p) => p.setAudioOutput({ deviceId })),
1480-
);
1481-
1482-
// On platforms where AudioContext.setSinkId is unavailable (e.g. iOS 26 Safari), audio
1483-
// is routed through a relay <audio> element whose setSinkId is called without a user
1484-
// gesture when a remote track is first received. iOS requires that setSinkId has been
1485-
// called at least once with a user gesture for a given deviceId before it will honour
1486-
// the call from a non-gesture context. When there are no remote participants yet (the
1487-
// user is switching the device while alone), we call setSinkId on a temporary element
1488-
// right now — within the current user gesture — to pre-grant that permission.
1489-
if (this.remoteParticipants.size === 0 && canSwitch && !audioContextHasSinkId) {
1490-
const permissionEl = document.createElement('audio');
1491-
await (permissionEl.setSinkId(deviceId) as Promise<void>).catch(() => {});
1478+
if (isSafariSpeakerSelectionSupported() && this.audioContext) {
1479+
// iOS 26 path: route via a single shared relay <audio> element on the AudioContext.
1480+
// Calling setSinkId here (within the user gesture) grants iOS permission for this
1481+
// element, which persists for the call — so new remote tracks joining later route
1482+
// through the already-permitted element without needing their own user gesture.
1483+
await (
1484+
getOrCreateSharedRelay(this.audioContext).relayElement.setSinkId(
1485+
deviceId,
1486+
) as Promise<void>
1487+
).catch((e) => {
1488+
this.log.warn('Failed to set sink id on shared relay element', e, this.logContext);
1489+
});
1490+
} else {
1491+
// Chrome / fallback: set on individual participant elements.
1492+
// Also needed for Chrome echo cancellation workaround with non-default output devices
1493+
// even when webAudioMix is enabled.
1494+
// see https://issues.chromium.org/issues/40252911#comment7
1495+
await Promise.all(
1496+
Array.from(this.remoteParticipants.values()).map((p) => p.setAudioOutput({ deviceId })),
1497+
);
14921498
}
14931499
} catch (e) {
14941500
this.options.audioOutput.deviceId = prevDeviceId;
@@ -1824,9 +1830,12 @@ class Room extends (EventEmitter as new () => TypedEmitter<RoomEventCallbacks>)
18241830
this.remoteParticipants.clear();
18251831
this.sidToIdentity.clear();
18261832
this.activeSpeakers = [];
1827-
if (this.audioContext && typeof this.options.webAudioMix === 'boolean') {
1828-
this.audioContext.close();
1829-
this.audioContext = undefined;
1833+
if (this.audioContext) {
1834+
disposeSharedRelay(this.audioContext);
1835+
if (typeof this.options.webAudioMix === 'boolean') {
1836+
this.audioContext.close();
1837+
this.audioContext = undefined;
1838+
}
18301839
}
18311840
if (isWeb()) {
18321841
window.removeEventListener('beforeunload', this.onPageLeave);

src/room/track/RemoteAudioTrack.ts

Lines changed: 25 additions & 42 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,12 @@ import { TrackEvent } from '../events';
22
import type { AudioReceiverStats } from '../stats';
33
import { computeBitrate } from '../stats';
44
import type { LoggerOptions } from '../types';
5-
import { isReactNative, supportsSetSinkId } from '../utils';
5+
import {
6+
getOrCreateSharedRelay,
7+
isReactNative,
8+
isSafariSpeakerSelectionSupported,
9+
supportsSetSinkId,
10+
} from '../utils';
611
import RemoteTrack from './RemoteTrack';
712
import { Track } from './Track';
813
import type { AudioOutputOptions } from './options';
@@ -18,15 +23,6 @@ export default class RemoteAudioTrack extends RemoteTrack<Track.Kind.Audio> {
1823

1924
private sourceNode?: MediaStreamAudioSourceNode;
2025

21-
/** Used on iOS >=26 Safari where AudioContext.setSinkId is unavailable.
22-
* On iOS26, WebRTC remote tracks bypass the HTMLMediaElement/AVPlayer pipeline — they go
23-
* through the WebRTC engine's own internal audio pipeline connected directly to
24-
* AVAudioSession — so HTMLMediaElement.setSinkId() has no effect on them.
25-
* Audio is therefore routed WebRTC→AudioContext→MediaStreamDestinationNode→this element.
26-
* This relay element's srcObject is AudioContext-generated, which goes through the normal
27-
* AVPlayer pipeline where setSinkId() is honoured. */
28-
private webAudioRelayElement?: HTMLAudioElement;
29-
3026
private webAudioPluginNodes: AudioNode[];
3127

3228
private sinkId?: string;
@@ -91,12 +87,15 @@ export default class RemoteAudioTrack extends RemoteTrack<Track.Kind.Audio> {
9187
*/
9288
async setSinkId(deviceId: string) {
9389
this.sinkId = deviceId;
94-
const targets: HTMLAudioElement[] = [
95-
...(this.attachedElements as HTMLAudioElement[]),
96-
...(this.webAudioRelayElement ? [this.webAudioRelayElement] : []),
97-
];
90+
// On iOS 26, audio routing is handled by the shared relay element at the AudioContext
91+
// level — see Room.switchActiveDevice. The attached elements here are muted/vol=0, and
92+
// calling setSinkId on them would throw NotAllowedError without a concurrent user gesture
93+
// (e.g. when a participant joins after a device switch).
94+
if (isSafariSpeakerSelectionSupported()) {
95+
return;
96+
}
9897
await Promise.all(
99-
targets.map((elm) => {
98+
this.attachedElements.map((elm) => {
10099
if (!supportsSetSinkId(elm)) {
101100
return;
102101
}
@@ -118,7 +117,10 @@ export default class RemoteAudioTrack extends RemoteTrack<Track.Kind.Audio> {
118117
super.attach(element);
119118
}
120119

121-
if (this.sinkId && supportsSetSinkId(element)) {
120+
// Skip setSinkId on the primary element on iOS 26: the element is muted/vol=0 below and
121+
// audio routing happens via the shared relay, so calling setSinkId here would only throw
122+
// NotAllowedError when no user gesture is active.
123+
if (this.sinkId && supportsSetSinkId(element) && !isSafariSpeakerSelectionSupported()) {
122124
(element.setSinkId(this.sinkId) as Promise<void>).catch((e) => {
123125
this.log.error('Failed to set sink id on remote audio track', e, this.logContext);
124126
});
@@ -209,26 +211,13 @@ export default class RemoteAudioTrack extends RemoteTrack<Track.Kind.Audio> {
209211
// AudioContext.setSinkId() is available (Chrome, Firefox etc.) — use it directly.
210212
this.gainNode.connect(context.destination);
211213
} else {
212-
// AudioContext.setSinkId() is not available (iOS 26 Safari).
213-
// On iOS, WebRTC remote tracks go through the WebRTC engine's own audio pipeline
214-
// (connected directly to AVAudioSession), bypassing the AVPlayer pipeline that
215-
// setSinkId() controls. Route through a MediaStreamDestinationNode so that the
216-
// relay element's srcObject is AudioContext-generated; that stream goes through
217-
// AVPlayer and setSinkId() is honoured.
218-
const destinationNode = context.createMediaStreamDestination();
219-
this.gainNode.connect(destinationNode);
220-
const relayEl = document.createElement('audio');
221-
relayEl.hidden = true;
222-
relayEl.autoplay = true;
223-
relayEl.srcObject = destinationNode.stream;
224-
document.body?.appendChild(relayEl);
225-
relayEl.play().catch(() => {});
226-
if (this.sinkId && supportsSetSinkId(relayEl)) {
227-
(relayEl.setSinkId(this.sinkId) as Promise<void>).catch((e) => {
228-
this.log.error('Failed to set sink id on web audio relay element', e, this.logContext);
229-
});
230-
}
231-
this.webAudioRelayElement = relayEl;
214+
// iOS 26 Safari: AudioContext.setSinkId() is unavailable AND HTMLMediaElement.setSinkId()
215+
// has no effect on elements backed by WebRTC remote tracks (those go through the WebRTC
216+
// engine's internal pipeline → AVAudioSession, bypassing AVPlayer). Route via a shared
217+
// MediaStreamDestinationNode + relay element so that setSinkId() — called once on the
218+
// shared relay element during the user gesture in Room.switchActiveDevice — applies to
219+
// all remote audio.
220+
this.gainNode.connect(getOrCreateSharedRelay(context).destinationNode);
232221
}
233222

234223
if (this.elementVolume) {
@@ -258,12 +247,6 @@ export default class RemoteAudioTrack extends RemoteTrack<Track.Kind.Audio> {
258247
this.sourceNode?.disconnect();
259248
this.gainNode = undefined;
260249
this.sourceNode = undefined;
261-
if (this.webAudioRelayElement) {
262-
this.webAudioRelayElement.pause();
263-
this.webAudioRelayElement.srcObject = null;
264-
this.webAudioRelayElement.parentElement?.removeChild(this.webAudioRelayElement);
265-
this.webAudioRelayElement = undefined;
266-
}
267250
}
268251

269252
protected monitorReceiver = async () => {

src/room/utils.test.ts

Lines changed: 45 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,6 @@ import { describe, expect, it } from 'vitest';
33
import { extractMaxAgeFromRequestHeaders, getClientInfo, splitUtf8, toWebsocketUrl } from './utils';
44
import { isSafariSpeakerSelectionSupported, supportsSetSinkId } from './utils';
55

6-
76
describe('toWebsocketUrl', () => {
87
it('leaves wss urls alone', () => {
98
expect(toWebsocketUrl('ws://mywebsite.com')).toEqual('ws://mywebsite.com');
@@ -193,45 +192,55 @@ describe('extractMaxAgeFromRequestHeaders', () => {
193192

194193
describe('isSafariSpeakerSelectionSupported', () => {
195194
it('returns true for Safari >= 26', () => {
196-
expect(isSafariSpeakerSelectionSupported({
197-
name: 'Safari',
198-
version: '26.0',
199-
os: 'macOS',
200-
osVersion: '26.0',
201-
})).toBe(true);
202-
expect(isSafariSpeakerSelectionSupported({
203-
name: 'Safari',
204-
version: '27.1',
205-
os: 'macOS',
206-
osVersion: '27.1',
207-
})).toBe(true);
195+
expect(
196+
isSafariSpeakerSelectionSupported({
197+
name: 'Safari',
198+
version: '26.0',
199+
os: 'macOS',
200+
osVersion: '26.0',
201+
}),
202+
).toBe(true);
203+
expect(
204+
isSafariSpeakerSelectionSupported({
205+
name: 'Safari',
206+
version: '27.1',
207+
os: 'macOS',
208+
osVersion: '27.1',
209+
}),
210+
).toBe(true);
208211
});
209212

210213
it('returns true for iOS Safari >= 26', () => {
211-
expect(isSafariSpeakerSelectionSupported({
212-
name: 'Safari',
213-
version: '26.0',
214-
os: 'iOS',
215-
osVersion: '26.0',
216-
})).toBe(true);
214+
expect(
215+
isSafariSpeakerSelectionSupported({
216+
name: 'Safari',
217+
version: '26.0',
218+
os: 'iOS',
219+
osVersion: '26.0',
220+
}),
221+
).toBe(true);
217222
});
218223

219224
it('returns false for Safari < 26', () => {
220-
expect(isSafariSpeakerSelectionSupported({
221-
name: 'Safari',
222-
version: '25.9',
223-
os: 'macOS',
224-
osVersion: '25.9',
225-
})).toBe(false);
225+
expect(
226+
isSafariSpeakerSelectionSupported({
227+
name: 'Safari',
228+
version: '25.9',
229+
os: 'macOS',
230+
osVersion: '25.9',
231+
}),
232+
).toBe(false);
226233
});
227234

228235
it('returns false for non-Safari browsers', () => {
229-
expect(isSafariSpeakerSelectionSupported({
230-
name: 'Chrome',
231-
version: '120.0',
232-
os: 'macOS',
233-
osVersion: '14.0',
234-
})).toBe(false);
236+
expect(
237+
isSafariSpeakerSelectionSupported({
238+
name: 'Chrome',
239+
version: '120.0',
240+
os: 'macOS',
241+
osVersion: '14.0',
242+
}),
243+
).toBe(false);
235244
});
236245
});
237246

@@ -246,18 +255,20 @@ describe('supportsSetSinkId', () => {
246255
});
247256
it('returns true if setSinkId is present', () => {
248257
Object.defineProperty(navigator, 'userAgent', {
249-
value: 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/26.0 Safari/605.1.15',
258+
value:
259+
'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/26.0 Safari/605.1.15',
250260
configurable: true,
251261
});
252262
const fakeAudio = { setSinkId: () => {} } as any as HTMLMediaElement;
253263
expect(supportsSetSinkId(fakeAudio)).toBe(true);
254264
});
255265
it('returns false if setSinkId not supported', () => {
256266
Object.defineProperty(navigator, 'userAgent', {
257-
value: 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.6 Safari/605.1.15',
267+
value:
268+
'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.6 Safari/605.1.15',
258269
configurable: true,
259270
});
260271
const fakeAudio = { setSinkId: () => {} } as any as HTMLMediaElement;
261272
expect(supportsSetSinkId(fakeAudio)).toBe(false);
262273
});
263-
});
274+
});

0 commit comments

Comments
 (0)