mirror of
https://github.com/fluxerapp/fluxer
synced 2026-10-17 13:05:32 +09:00
fix(voice): release the mic after a region hot-swap (#3490)
This commit is contained in:
1 parent
c454add5c8
commit
8c38e06a15
3 files changed
+102
-7
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});
|
||||
}
|
||||
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);
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user