fix(voice): release the mic after a region hot-swap (#3490)

This commit is contained in:
Hampus authored and GitHub committed 2026-10-15 04:26:44 +02:00
1 parent c454add5c8
commit 8c38e06a15
3 files changed
+98 -3

No files matched your search

@@ -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<typeof fakeRoom>, 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);
});
});
@@ -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<LocalParticipant, 'audioTrackPublications'> {}
interface LocalParticipantWithTrackPublications extends Pick<LocalParticipant, 'trackPublications'> {}
interface LocalParticipantWithUnpublish extends LocalParticipantWithTrackPublications {
unpublishTrack: LocalParticipant['unpublishTrack'];
}
interface LocalParticipantWithUnpublishEvents extends Pick<LocalParticipant, 'on' | 'off'> {}
const republishedCaptureOwners = new WeakMap<MediaStreamTrack, LocalTrack>();
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);
}
@@ -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});
}
} catch (error) {
logger.warn('Failed to disconnect previous room', error);
}