From 8c38e06a15d32c8863ca7f9a7f3c040e31663696 Mon Sep 17 00:00:00 2001 From: Hampus Date: Thu, 15 Oct 2026 04:26:44 +0200 Subject: [PATCH] fix(voice): release the mic after a region hot-swap (#3490) --- .../engine/VoiceTrackPublicationUtils.test.ts | 72 +++++++++++++++++++ .../engine/VoiceTrackPublicationUtils.ts | 21 ++++++ .../VoiceEngineV2AppConnectionHostAdapter.ts | 16 +++-- 3 files changed, 102 insertions(+), 7 deletions(-) create mode 100644 fluxer_app/src/features/voice/engine/VoiceTrackPublicationUtils.test.ts diff --git a/fluxer_app/src/features/voice/engine/VoiceTrackPublicationUtils.test.ts b/fluxer_app/src/features/voice/engine/VoiceTrackPublicationUtils.test.ts new file mode 100644 index 000000000..00c7e3305 --- /dev/null +++ b/fluxer_app/src/features/voice/engine/VoiceTrackPublicationUtils.test.ts @@ -0,0 +1,72 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later + +import {EventEmitter} from 'node:events'; +import {releaseCaptureWithRepublishedTrack} from '@app/features/voice/engine/VoiceTrackPublicationUtils'; +import {type LocalParticipant, type LocalTrack, type LocalTrackPublication, ParticipantEvent} from 'livekit-client'; +import {describe, expect, it, vi} from 'vitest'; + +function fakeMediaStreamTrack(): MediaStreamTrack { + return {readyState: 'live'} as MediaStreamTrack; +} + +function fakeLocalTrack(mediaStreamTrack: MediaStreamTrack): LocalTrack { + return {mediaStreamTrack, stop: vi.fn()} as unknown as LocalTrack; +} + +function fakeRoom(): {participant: LocalParticipant; unpublish(publication: LocalTrackPublication): void} { + const emitter = new EventEmitter(); + return { + participant: emitter as unknown as LocalParticipant, + unpublish: (publication) => emitter.emit(ParticipantEvent.LocalTrackUnpublished, publication), + }; +} + +function republish(room: ReturnType, sourceTrack: LocalTrack): LocalTrackPublication { + const publication = {track: fakeLocalTrack(sourceTrack.mediaStreamTrack)} as LocalTrackPublication; + releaseCaptureWithRepublishedTrack(room.participant, publication, sourceTrack); + return publication; +} + +function stopTrack(track: MediaStreamTrack): void { + Object.assign(track, {readyState: 'ended'}); +} + +describe('releaseCaptureWithRepublishedTrack', () => { + it('stops the processed microphone capture when its hot-swapped publication is stopped on leave', () => { + const processedTrack = fakeMediaStreamTrack(); + const microphone = fakeLocalTrack(processedTrack); + const newRoom = fakeRoom(); + const publication = republish(newRoom, microphone); + + stopTrack(processedTrack); + newRoom.unpublish(publication); + + expect(microphone.stop).toHaveBeenCalledTimes(1); + }); + + it('keeps the capture running when the publication is handed to another room without stopping', () => { + const processedTrack = fakeMediaStreamTrack(); + const microphone = fakeLocalTrack(processedTrack); + const newRoom = fakeRoom(); + const publication = republish(newRoom, microphone); + + newRoom.unpublish(publication); + + expect(microphone.stop).not.toHaveBeenCalled(); + }); + + it('stops the original capture after a second hot-swap republishes the wrapper track', () => { + const processedTrack = fakeMediaStreamTrack(); + const microphone = fakeLocalTrack(processedTrack); + const secondRoom = fakeRoom(); + const secondPublication = republish(secondRoom, microphone); + const thirdRoom = fakeRoom(); + const thirdPublication = republish(thirdRoom, secondPublication.track!); + + secondRoom.unpublish(secondPublication); + stopTrack(processedTrack); + thirdRoom.unpublish(thirdPublication); + + expect(microphone.stop).toHaveBeenCalledTimes(1); + }); +}); diff --git a/fluxer_app/src/features/voice/engine/VoiceTrackPublicationUtils.ts b/fluxer_app/src/features/voice/engine/VoiceTrackPublicationUtils.ts index ea87382a3..d9dae294d 100644 --- a/fluxer_app/src/features/voice/engine/VoiceTrackPublicationUtils.ts +++ b/fluxer_app/src/features/voice/engine/VoiceTrackPublicationUtils.ts @@ -6,12 +6,16 @@ import { VoiceTrackSource as VoiceTrackSourceValue, } from '@app/features/voice/engine/VoiceTrackSource'; import type {LocalParticipant, LocalTrack, LocalTrackPublication} from 'livekit-client'; +import {ParticipantEvent} from 'livekit-client'; interface LocalParticipantWithAudioPublications extends Pick {} interface LocalParticipantWithTrackPublications extends Pick {} interface LocalParticipantWithUnpublish extends LocalParticipantWithTrackPublications { unpublishTrack: LocalParticipant['unpublishTrack']; } +interface LocalParticipantWithUnpublishEvents extends Pick {} + +const republishedCaptureOwners = new WeakMap(); export type LocalMediaPublicationSlot = 'camera' | 'screen_share'; @@ -246,3 +250,20 @@ export function selectLocalMediaPublicationsForConnectionRepublish( } return selected; } + +export function releaseCaptureWithRepublishedTrack( + participant: LocalParticipantWithUnpublishEvents, + republished: LocalTrackPublication, + sourceTrack: LocalTrack, +): void { + const publishedTrack = sourceTrack.mediaStreamTrack; + const captureOwner = republishedCaptureOwners.get(publishedTrack) ?? sourceTrack; + republishedCaptureOwners.set(publishedTrack, captureOwner); + const republishedTrack = republished.track; + const releaseCapture = (publication: LocalTrackPublication): void => { + if (publication.track !== republishedTrack || publishedTrack.readyState !== 'ended') return; + participant.off(ParticipantEvent.LocalTrackUnpublished, releaseCapture); + captureOwner.stop(); + }; + participant.on(ParticipantEvent.LocalTrackUnpublished, releaseCapture); +} diff --git a/fluxer_app/src/features/voice/engine/v2/VoiceEngineV2AppConnectionHostAdapter.ts b/fluxer_app/src/features/voice/engine/v2/VoiceEngineV2AppConnectionHostAdapter.ts index ef0ec7490..ff4fba187 100644 --- a/fluxer_app/src/features/voice/engine/v2/VoiceEngineV2AppConnectionHostAdapter.ts +++ b/fluxer_app/src/features/voice/engine/v2/VoiceEngineV2AppConnectionHostAdapter.ts @@ -29,7 +29,10 @@ import { releaseE2EEWorker, } from '@app/features/voice/engine/VoiceE2EEKeyProvider'; import {getSharedVoiceAudioContext} from '@app/features/voice/engine/VoiceSharedAudioContext'; -import {selectLocalMediaPublicationsForConnectionRepublish} from '@app/features/voice/engine/VoiceTrackPublicationUtils'; +import { + releaseCaptureWithRepublishedTrack, + selectLocalMediaPublicationsForConnectionRepublish, +} from '@app/features/voice/engine/VoiceTrackPublicationUtils'; import { assertDisconnectReason, assertNonEmptyString, @@ -819,6 +822,7 @@ export class VoiceEngineV2AppConnectionHostAdapter extends Store { name: publication.trackName, }; const republished = await newParticipant.publishTrack(track.mediaStreamTrack, publishOptions); + releaseCaptureWithRepublishedTrack(newParticipant, republished, track); const violation = publishOptions.videoCodec ? findVideoPublishCodecPolicyViolation(publishOptions.videoCodec, republished.options?.videoCodec) : null; @@ -1096,12 +1100,10 @@ export class VoiceEngineV2AppConnectionHostAdapter extends Store { private disconnectPreviousRoom(previousRoom: Room | null, stopTracks = true): void { if (!previousRoom) return; try { - if (previousRoom.state === 'connected') { - const tracksToStop = stopTracks ? [] : this.getPreviousRoomNonScreenShareTracks(previousRoom); - previousRoom.disconnect(stopTracks); - this.stopPreviousRoomTracks(tracksToStop); - logger.debug('Previous room disconnected', {stopTracks}); - } + const tracksToStop = stopTracks ? [] : this.getPreviousRoomNonScreenShareTracks(previousRoom); + previousRoom.disconnect(stopTracks); + this.stopPreviousRoomTracks(tracksToStop); + logger.debug('Previous room disconnected', {stopTracks}); } catch (error) { logger.warn('Failed to disconnect previous room', error); }