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