From e87b05e429dbf5df024f60a6bdff623f2d2770d8 Mon Sep 17 00:00:00 2001 From: "daniel.phillip" Date: Mon, 4 May 2026 10:20:14 +0000 Subject: [PATCH 1/2] added test to identify voice islands and applied fix --- mail_livekit/__manifest__.py | 6 + .../static/src/discuss/livekit_adapter.js | 26 +++-- .../static/src/discuss/livekit_service.js | 6 +- .../static/tests/livekit_adapter.test.js | 110 ++++++++++++++++++ 4 files changed, 137 insertions(+), 11 deletions(-) create mode 100644 mail_livekit/static/tests/livekit_adapter.test.js diff --git a/mail_livekit/__manifest__.py b/mail_livekit/__manifest__.py index d7e0157f..d127e52f 100644 --- a/mail_livekit/__manifest__.py +++ b/mail_livekit/__manifest__.py @@ -21,6 +21,12 @@ "mail_livekit/static/src/discuss/call_participant_video_patch.js", "mail_livekit/static/src/discuss/call_context_menu_patch.js", ], + "web.assets_unit_tests": [ + "mail_livekit/static/lib/livekit/livekit-client.umd.min.js", + "mail_livekit/static/src/discuss/livekit_service.js", + "mail_livekit/static/src/discuss/livekit_adapter.js", + "mail_livekit/static/tests/**/*", + ], }, "external_dependencies": { "python": ["livekit-api"], diff --git a/mail_livekit/static/src/discuss/livekit_adapter.js b/mail_livekit/static/src/discuss/livekit_adapter.js index f0d5575b..54420aa7 100644 --- a/mail_livekit/static/src/discuss/livekit_adapter.js +++ b/mail_livekit/static/src/discuss/livekit_adapter.js @@ -1,3 +1,5 @@ +/** @odoo-module */ + import {Source, livekitService} from "./livekit_service"; export class LiveKitAdapter { @@ -29,12 +31,13 @@ export class LiveKitAdapter { } async updateUpload(source, track) { - const livekitSource = - source === "audio" - ? Source.MICROPHONE - : source === "camera" - ? Source.CAMERA - : Source.SCREEN; + const livekitSource = Object.values(Source).includes(source) + ? source + : source === "audio" + ? Source.MICROPHONE + : source === "camera" + ? Source.CAMERA + : Source.SCREEN; await livekitService?.setTrackEnabled(livekitSource, Boolean(track), track); } @@ -87,14 +90,14 @@ export class LiveKitAdapter { }); livekitService.subscribeToTrackSubscribed( "adapter", - (participantId, source, track) => { + (participantId, source, track, audioElement = null) => { console.debug( "received Track subscribed event:", participantId, source, track ); - this.handleTrackSubscribed(participantId, source, track); + this.handleTrackSubscribed(participantId, source, track, audioElement); } ); livekitService.subscribeToTrackMuted( @@ -113,8 +116,13 @@ export class LiveKitAdapter { } async connect(livekit_url, token) { - await livekitService?.connect(livekit_url, token); this.addLivekitListeners(); + try { + await livekitService?.connect(livekit_url, token); + } catch (error) { + await livekitService?.disconnect(); + throw error; + } } async disconnect() { diff --git a/mail_livekit/static/src/discuss/livekit_service.js b/mail_livekit/static/src/discuss/livekit_service.js index 243cb2dc..875d5eca 100644 --- a/mail_livekit/static/src/discuss/livekit_service.js +++ b/mail_livekit/static/src/discuss/livekit_service.js @@ -1,3 +1,5 @@ +/** @odoo-module */ + const {Room, VideoPresets, RoomEvent} = window.LivekitClient; // This script should be the only contact point with Livekit SDK @@ -151,8 +153,8 @@ class LivekitService { // Add this method to the LivekitService class async rebindExistingTracks() { - if (!this.room || !this.connected) { - log("Cannot rebind - not connected to room"); + if (!this.room) { + log("Cannot rebind - no LiveKit room"); return; } diff --git a/mail_livekit/static/tests/livekit_adapter.test.js b/mail_livekit/static/tests/livekit_adapter.test.js new file mode 100644 index 00000000..9c255ae3 --- /dev/null +++ b/mail_livekit/static/tests/livekit_adapter.test.js @@ -0,0 +1,110 @@ +import {after, afterEach, describe, expect, test} from "@odoo/hoot"; +import {Source, livekitService} from "@mail_livekit/discuss/livekit_service"; +import {LiveKitAdapter} from "@mail_livekit/discuss/livekit_adapter"; + +const originalLivekitClient = window.LivekitClient; + +function cleanupLivekitService() { + livekitService.infoChangeListeners.clear(); + livekitService.trackSubscribedListeners.clear(); + livekitService.trackMutedListeners.clear(); + livekitService.room = null; + livekitService.connected = false; + livekitService.initiated = false; + document + .querySelectorAll(`.${livekitService.audioElementClass}`) + .forEach((element) => element.remove()); +} + +function makeRemoteAudioTrack(identity) { + const audioElement = document.createElement("audio"); + const track = { + kind: "audio", + attach: () => audioElement, + detach: () => { + // Detach the audio element + }, + }; + const publication = { + source: Source.MICROPHONE, + track, + isSubscribed: true, + }; + const participant = { + identity, + sid: `${identity}-sid`, + audioTrackPublications: new Map([["microphone", publication]]), + videoTrackPublications: new Map(), + }; + return {audioElement, participant, publication, track}; +} + +describe("mail_livekit livekit adapter", () => { + after(() => { + window.LivekitClient = originalLivekitClient; + }); + + afterEach(() => { + cleanupLivekitService(); + }); + + test("connect does not drop audio subscribed while the room connection is starting", async () => { + const adapter = new LiveKitAdapter(); + const emittedPayloads = []; + const {audioElement, participant, publication, track} = + makeRemoteAudioTrack("partner:8"); + const originalConnect = livekitService.connect; + + livekitService.connect = async () => { + livekitService.handleTrackSubscribed(track, publication, participant); + }; + + adapter.addEventListener("setAudioVolume", (event) => { + emittedPayloads.push(event.detail.payload); + }); + + try { + await adapter.connect("wss://livekit.example", "token"); + } finally { + livekitService.connect = originalConnect; + } + + expect(emittedPayloads).toEqual([ + { + element: audioElement, + identity: "partner:8", + }, + ]); + }); + + test("rebindExistingTracks replays audio already subscribed during connection startup", async () => { + const {audioElement, participant, track} = makeRemoteAudioTrack("partner:9"); + const subscribedTracks = []; + + livekitService.room = { + remoteParticipants: new Map([[participant.identity, participant]]), + }; + livekitService.connected = false; + livekitService.subscribeToTrackSubscribed( + "adapter", + (identity, source, subscribedTrack, element) => { + subscribedTracks.push({ + element, + identity, + source, + track: subscribedTrack, + }); + } + ); + + await livekitService.rebindExistingTracks(); + + expect(subscribedTracks.length).toBe(1); + expect(subscribedTracks[0]).toEqual({ + element: audioElement, + identity: "partner:9", + source: Source.MICROPHONE, + track, + }); + }); +}); From 74e091247db86f6aaa937eeb6b9a8dbb6ef20349 Mon Sep 17 00:00:00 2001 From: "daniel.phillip" Date: Wed, 13 May 2026 06:40:34 +0000 Subject: [PATCH 2/2] improved rebinding of subscribed tracks --- .../static/src/discuss/livekit_service.js | 3 ++ .../static/tests/livekit_adapter.test.js | 39 +++++++++++++++++++ 2 files changed, 42 insertions(+) diff --git a/mail_livekit/static/src/discuss/livekit_service.js b/mail_livekit/static/src/discuss/livekit_service.js index 875d5eca..34754404 100644 --- a/mail_livekit/static/src/discuss/livekit_service.js +++ b/mail_livekit/static/src/discuss/livekit_service.js @@ -173,6 +173,9 @@ class LivekitService { publication, participant ); + } else if (!publication.isSubscribed) { + log(`Requesting missing audio subscription for ${identity}`); + publication.setSubscribed?.(true); } } diff --git a/mail_livekit/static/tests/livekit_adapter.test.js b/mail_livekit/static/tests/livekit_adapter.test.js index 9c255ae3..dd6fce27 100644 --- a/mail_livekit/static/tests/livekit_adapter.test.js +++ b/mail_livekit/static/tests/livekit_adapter.test.js @@ -39,6 +39,24 @@ function makeRemoteAudioTrack(identity) { return {audioElement, participant, publication, track}; } +function makeRemoteAudioPublication(identity, {isSubscribed = false} = {}) { + const publication = { + source: Source.MICROPHONE, + track: null, + isSubscribed, + setSubscribed: (subscribed) => { + publication.requestedSubscription = subscribed; + }, + }; + const participant = { + identity, + sid: `${identity}-sid`, + audioTrackPublications: new Map([["microphone", publication]]), + videoTrackPublications: new Map(), + }; + return {participant, publication}; +} + describe("mail_livekit livekit adapter", () => { after(() => { window.LivekitClient = originalLivekitClient; @@ -107,4 +125,25 @@ describe("mail_livekit livekit adapter", () => { track, }); }); + + test("rebindExistingTracks requests missing remote microphone subscriptions in a multi-person call", async () => { + const {participant: alreadySubscribed} = makeRemoteAudioTrack("partner:10"); + const {participant: missingPeerA, publication: missingPublicationA} = + makeRemoteAudioPublication("partner:11"); + const {participant: missingPeerB, publication: missingPublicationB} = + makeRemoteAudioPublication("partner:12"); + + livekitService.room = { + remoteParticipants: new Map([ + [alreadySubscribed.identity, alreadySubscribed], + [missingPeerA.identity, missingPeerA], + [missingPeerB.identity, missingPeerB], + ]), + }; + + await livekitService.rebindExistingTracks(); + + expect(missingPublicationA.requestedSubscription).toBe(true); + expect(missingPublicationB.requestedSubscription).toBe(true); + }); });