@@ -103,17 +103,20 @@ import {
103103import {
104104 Future ,
105105 createDummyVideoStreamTrack ,
106+ disposeSharedRelay ,
106107 extractChatMessage ,
107108 extractTranscriptionSegments ,
108109 getDisconnectReasonFromConnectionError ,
109110 getEmptyAudioStreamTrack ,
111+ getOrCreateSharedRelay ,
110112 isBrowserSupported ,
111113 isCloud ,
112114 isLocalAudioTrack ,
113115 isLocalParticipant ,
114116 isReactNative ,
115117 isRemotePub ,
116118 isSafariBased ,
119+ isSafariSpeakerSelectionSupported ,
117120 isWeb ,
118121 numberToBigInt ,
119122 sleep ,
@@ -1446,14 +1449,28 @@ class Room extends (EventEmitter as new () => TypedEmitter<RoomEventCallbacks>)
14461449 if ( success && isMuted ) shouldTriggerImmediateDeviceChange = true ;
14471450 } else if ( kind === 'audiooutput' ) {
14481451 shouldTriggerImmediateDeviceChange = true ;
1449- if (
1450- ( ! supportsSetSinkId ( ) && ! this . options . webAudioMix ) ||
1451- ( this . options . webAudioMix && this . audioContext && ! ( 'setSinkId' in this . audioContext ) )
1452- ) {
1452+ // True when we can route output via AudioContext.setSinkId directly, e.g.,
1453+ // Chrome / Edge / Firefox + webAudioMix : true (use AudioContext.setSinkId)
1454+ // Safari macOS (any version) : false (AudioContext.setSinkId not implemented)
1455+ // Safari iOS (any version) : false (AudioContext.setSinkId not implemented)
1456+ // any browser without webAudioMix : false
1457+ const audioContextHasSinkId =
1458+ this . options . webAudioMix && ! ! this . audioContext && 'setSinkId' in this . audioContext ;
1459+ // True when we route output via the shared relay <audio> element (iOS 26 path).
1460+ // iOS 26+ Safari + webAudioMix : true
1461+ // macOS Safari 26+ + webAudioMix : true (also benefits from the relay approach)
1462+ // anything else : false
1463+ const useSharedRelay = isSafariSpeakerSelectionSupported ( ) && ! ! this . audioContext ;
1464+ // Throw only when neither HTMLMediaElement.setSinkId nor AudioContext.setSinkId is usable.
1465+ // When webAudioMix=true but AudioContext.setSinkId is unavailable (e.g. iOS 26 Safari),
1466+ // we fall through and rely on the shared relay-element setSinkId path.
1467+ if ( ! supportsSetSinkId ( ) && ! audioContextHasSinkId ) {
14531468 throw new Error ( 'cannot switch audio output, the current browser does not support it' ) ;
14541469 }
1455- if ( this . options . webAudioMix ) {
1456- // setting `default` for web audio output doesn't work, so we need to normalize the id before
1470+ if ( audioContextHasSinkId ) {
1471+ // AudioContext.setSinkId (Chrome) doesn't accept 'default', so resolve to a real device id.
1472+ // On iOS 26 we use HTMLMediaElement.setSinkId on a relay element instead, which does
1473+ // accept 'default', so skip normalization there.
14571474 deviceId =
14581475 ( await DeviceManager . getInstance ( ) . normalizeDeviceId ( 'audiooutput' , deviceId ) ) ?? '' ;
14591476 }
@@ -1462,16 +1479,28 @@ class Room extends (EventEmitter as new () => TypedEmitter<RoomEventCallbacks>)
14621479 this . options . audioOutput . deviceId = deviceId ;
14631480
14641481 try {
1465- if ( this . options . webAudioMix ) {
1482+ if ( audioContextHasSinkId ) {
14661483 // @ts -expect-error setSinkId is not yet in the typescript type of AudioContext
14671484 this . audioContext ?. setSinkId ( deviceId ) ;
14681485 }
14691486
1470- // 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
1471- // see https://issues.chromium.org/issues/40252911#comment7
1472- await Promise . all (
1473- Array . from ( this . remoteParticipants . values ( ) ) . map ( ( p ) => p . setAudioOutput ( { deviceId } ) ) ,
1474- ) ;
1487+ if ( useSharedRelay ) {
1488+ // iOS 26 path: route via a single shared relay <audio> element on the AudioContext.
1489+ // Calling setSinkId here (within the user gesture) grants iOS permission for this
1490+ // element, which persists for the call — so new remote tracks joining later route
1491+ // through the already-permitted element without needing their own user gesture.
1492+ await ( getOrCreateSharedRelay ( this . audioContext ! ) . relayElement . setSinkId (
1493+ deviceId ,
1494+ ) as Promise < void > ) ;
1495+ } else {
1496+ // Standard path for browsers with HTMLMediaElement.setSinkId (Chrome, Firefox, etc.):
1497+ // apply setSinkId on each participant's attached <audio> element directly.
1498+ // Note: Chrome with webAudioMix=true also needs this for echo cancellation with
1499+ // non-default output devices — see https://issues.chromium.org/issues/40252911#comment7
1500+ await Promise . all (
1501+ Array . from ( this . remoteParticipants . values ( ) ) . map ( ( p ) => p . setAudioOutput ( { deviceId } ) ) ,
1502+ ) ;
1503+ }
14751504 } catch ( e ) {
14761505 this . options . audioOutput . deviceId = prevDeviceId ;
14771506 throw e ;
@@ -1806,9 +1835,12 @@ class Room extends (EventEmitter as new () => TypedEmitter<RoomEventCallbacks>)
18061835 this . remoteParticipants . clear ( ) ;
18071836 this . sidToIdentity . clear ( ) ;
18081837 this . activeSpeakers = [ ] ;
1809- if ( this . audioContext && typeof this . options . webAudioMix === 'boolean' ) {
1810- this . audioContext . close ( ) ;
1811- this . audioContext = undefined ;
1838+ if ( this . audioContext ) {
1839+ disposeSharedRelay ( this . audioContext ) ;
1840+ if ( typeof this . options . webAudioMix === 'boolean' ) {
1841+ this . audioContext . close ( ) ;
1842+ this . audioContext = undefined ;
1843+ }
18121844 }
18131845 if ( isWeb ( ) ) {
18141846 window . removeEventListener ( 'beforeunload' , this . onPageLeave ) ;
@@ -2229,7 +2261,15 @@ class Room extends (EventEmitter as new () => TypedEmitter<RoomEventCallbacks>)
22292261 continue ;
22302262 }
22312263 const devicesOfKind = availableDevices . filter ( ( d ) => d . kind === kind ) ;
2232- const activeDevice = this . getActiveDevice ( kind ) ;
2264+ // For audiooutput, also check options.audioOutput.deviceId as a fallback: switchActiveDevice
2265+ // sets that field before awaiting setSinkId, but only updates activeDeviceMap after the
2266+ // await resolves. If a devicechange handler fires inside that window before any audiooutput
2267+ // switch has been recorded, activeDeviceMap is empty and the fallback yields the in-flight
2268+ // selection rather than nothing.
2269+ const activeDevice =
2270+ kind === 'audiooutput'
2271+ ? ( this . getActiveDevice ( kind ) ?? this . options . audioOutput ?. deviceId )
2272+ : this . getActiveDevice ( kind ) ;
22332273
22342274 if ( activeDevice === previousDevices . filter ( ( info ) => info . kind === kind ) [ 0 ] ?. deviceId ) {
22352275 // in Safari the first device is always the default, so we assume a user on the default device would like to switch to the default once it changes
@@ -2247,18 +2287,19 @@ class Room extends (EventEmitter as new () => TypedEmitter<RoomEventCallbacks>)
22472287 // switch to first available device if previously active device is not available any more
22482288 if (
22492289 devicesOfKind . length > 0 &&
2250- ! devicesOfKind . find ( ( deviceInfo ) => deviceInfo . deviceId === this . getActiveDevice ( kind ) ) &&
2290+ ! devicesOfKind . find ( ( deviceInfo ) => deviceInfo . deviceId === activeDevice ) &&
22512291 // avoid switching audio output on safari without explicit user action as it leads to slowed down audio playback
2252- ( kind !== 'audiooutput' || ! isSafariBased ( ) )
2292+ // exception: iOS/Safari 26+ supports the Speaker Selection API
2293+ ( kind !== 'audiooutput' || ! isSafariBased ( ) || isSafariSpeakerSelectionSupported ( ) )
22532294 ) {
22542295 await this . switchActiveDevice ( kind , devicesOfKind [ 0 ] . deviceId ) ;
22552296 }
22562297 }
22572298 }
22582299
22592300 private handleDeviceChange = async ( ) => {
2260- if ( getBrowser ( ) ?. os !== 'iOS' ) {
2261- // default devices are non deterministic on iOS, so we don't attempt to select them here
2301+ if ( getBrowser ( ) ?. os !== 'iOS' || isSafariSpeakerSelectionSupported ( ) ) {
2302+ // default devices are non deterministic on iOS (pre-26) , so we don't attempt to select them here
22622303 await this . selectDefaultDevices ( ) ;
22632304 }
22642305 this . emit ( RoomEvent . MediaDevicesChanged ) ;
0 commit comments