@@ -2,7 +2,12 @@ import { TrackEvent } from '../events';
22import type { AudioReceiverStats } from '../stats' ;
33import { computeBitrate } from '../stats' ;
44import type { LoggerOptions } from '../types' ;
5- import { isReactNative , supportsSetSinkId } from '../utils' ;
5+ import {
6+ getOrCreateSharedRelay ,
7+ isReactNative ,
8+ isSafariSpeakerSelectionSupported ,
9+ supportsSetSinkId ,
10+ } from '../utils' ;
611import RemoteTrack from './RemoteTrack' ;
712import { Track } from './Track' ;
813import 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 ( ) => {
0 commit comments