From 7307f67c072b7a04c6d0d508bbfa9e4ca8307a9a Mon Sep 17 00:00:00 2001 From: Chinmay V <203952148+chinmayv095@users.noreply.github.com> Date: Thu, 20 Aug 2026 01:50:48 +0530 Subject: [PATCH] fix(client): allow forcing the dual peer connection path on WebRTC livekit-client defaults singlePeerConnection to true, so the v1 join protocol bundles the publisher offer in the JoinRequest and moves the microphone request earlier in the connection sequence. iOS and iPadOS Safari reject it there. Add a webRtc.singlePeerConnection session option so callers can opt back into the dual peer connection path, following the shape #934 landed for webRtc.iceTransportPolicy. Left undefined when unset, so livekit-client's own default continues to apply. Fixes #960 --- .../webrtc-single-peer-connection-option.md | 5 + packages/client/src/utils/BaseConnection.ts | 8 ++ .../client/src/utils/WebRTCConnection.test.ts | 91 +++++++++++++++++++ packages/client/src/utils/WebRTCConnection.ts | 10 +- 4 files changed, 113 insertions(+), 1 deletion(-) create mode 100644 .changeset/webrtc-single-peer-connection-option.md diff --git a/.changeset/webrtc-single-peer-connection-option.md b/.changeset/webrtc-single-peer-connection-option.md new file mode 100644 index 00000000..ff59e5be --- /dev/null +++ b/.changeset/webrtc-single-peer-connection-option.md @@ -0,0 +1,5 @@ +--- +"@elevenlabs/client": minor +--- + +Add an optional `webRtc.singlePeerConnection` session option for WebRTC connections. Set it to `false` to force LiveKit's dual peer connection path, which restores the microphone request timing used before livekit-client began defaulting to the v1 join protocol. Leaving it unset keeps livekit-client's own default. diff --git a/packages/client/src/utils/BaseConnection.ts b/packages/client/src/utils/BaseConnection.ts index b9ecf9d4..63bf4b44 100644 --- a/packages/client/src/utils/BaseConnection.ts +++ b/packages/client/src/utils/BaseConnection.ts @@ -38,6 +38,14 @@ export type BaseSessionConfig = { * Defaults to "all". */ iceTransportPolicy?: "all" | "relay"; + /** + * Whether to negotiate over a single peer connection (LiveKit's v1 join + * protocol, which bundles the publisher offer in the JoinRequest). Set to + * false to force the dual peer connection path, e.g. on platforms that + * reject the microphone request at the point v1 issues it. Defaults to + * livekit-client's own default, currently true. + */ + singlePeerConnection?: boolean; }; overrides?: { agent?: { diff --git a/packages/client/src/utils/WebRTCConnection.test.ts b/packages/client/src/utils/WebRTCConnection.test.ts index 6d7005c1..96e18157 100644 --- a/packages/client/src/utils/WebRTCConnection.test.ts +++ b/packages/client/src/utils/WebRTCConnection.test.ts @@ -165,6 +165,97 @@ describe("WebRTCConnection", () => { connection.close(); }); + describe("webRtc.singlePeerConnection", () => { + // Options the Room constructor was last called with, if any. + function lastRoomOptions(): { singlePeerConnection?: boolean } | undefined { + return (Room as unknown as ReturnType).mock.calls.at( + -1 + )?.[0]; + } + + // Returns a room mock whose connect sequence resolves, so create() runs to + // completion and we can inspect how the Room itself was constructed. + function mockConnectingRoom() { + const mockRoom = new Room() as any; + (mockRoom.on as ReturnType).mockImplementation( + (event: string, callback: () => void) => { + if (event === "connected") { + queueMicrotask(callback); + } + } + ); + (mockRoom.once as ReturnType).mockImplementation( + (event: string, callback: () => void) => { + if (event === "signalConnected") { + queueMicrotask(callback); + } + } + ); + return mockRoom; + } + + it("forces the dual peer connection path when set to false", async () => { + mockConnectingRoom(); + + const connection = await WebRTCConnection.create({ + conversationToken: "test-token", + connectionType: "webrtc", + webRtc: { singlePeerConnection: false }, + }); + + expect(Room).toHaveBeenLastCalledWith({ singlePeerConnection: false }); + + connection.close(); + }); + + it("passes the option through when set to true", async () => { + mockConnectingRoom(); + + const connection = await WebRTCConnection.create({ + conversationToken: "test-token", + connectionType: "webrtc", + webRtc: { singlePeerConnection: true }, + }); + + expect(Room).toHaveBeenLastCalledWith({ singlePeerConnection: true }); + + connection.close(); + }); + + // The back-compat control: an unset option must leave livekit-client's own + // default in place rather than pinning it from here. Asserts that no mode + // was pinned rather than the exact call arity, since `new Room()` and + // `new Room(undefined)` are the same thing to LiveKit. + it("leaves the LiveKit default alone when the option is omitted", async () => { + mockConnectingRoom(); + + const connection = await WebRTCConnection.create({ + conversationToken: "test-token", + connectionType: "webrtc", + }); + + expect(lastRoomOptions()?.singlePeerConnection).toBeUndefined(); + + connection.close(); + }); + + // The two webRtc options are independent: setting the sibling must not + // start pinning the peer connection mode. + it("does not pin the peer connection mode when only iceTransportPolicy is set", async () => { + mockConnectingRoom(); + + const connection = await WebRTCConnection.create({ + conversationToken: "test-token", + connectionType: "webrtc", + webRtc: { iceTransportPolicy: "relay" }, + }); + + expect(lastRoomOptions()?.singlePeerConnection).toBeUndefined(); + + connection.close(); + }); + }); + it("reconnects input analyser after unmuting", async () => { const mockRoom = new Room() as any; diff --git a/packages/client/src/utils/WebRTCConnection.ts b/packages/client/src/utils/WebRTCConnection.ts index 82165db2..a74a6d87 100644 --- a/packages/client/src/utils/WebRTCConnection.ts +++ b/packages/client/src/utils/WebRTCConnection.ts @@ -289,7 +289,15 @@ export class WebRTCConnection extends BaseConnection { ); } - const room = new Room(); + // livekit-client defaults `singlePeerConnection` to true, which bundles the + // publisher offer in the JoinRequest and moves the microphone request + // earlier in the connection sequence. Callers that need the dual peer + // connection path can opt back into it. Left undefined so the LiveKit + // default applies unless the caller says otherwise. + const singlePeerConnection = config.webRtc?.singlePeerConnection; + const room = new Room( + singlePeerConnection === undefined ? undefined : { singlePeerConnection } + ); try { // Create connection instance first to set up event listeners