mirror of
https://github.com/fluxerapp/fluxer
synced 2026-10-17 21:15: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,
|
VoiceTrackSource as VoiceTrackSourceValue,
|
||||||
} from '@app/features/voice/engine/VoiceTrackSource';
|
} from '@app/features/voice/engine/VoiceTrackSource';
|
||||||
import type {LocalParticipant, LocalTrack, LocalTrackPublication} from 'livekit-client';
|
import type {LocalParticipant, LocalTrack, LocalTrackPublication} from 'livekit-client';
|
||||||
|
import {ParticipantEvent} from 'livekit-client';
|
||||||
|
|
||||||
interface LocalParticipantWithAudioPublications extends Pick<LocalParticipant, 'audioTrackPublications'> {}
|
interface LocalParticipantWithAudioPublications extends Pick<LocalParticipant, 'audioTrackPublications'> {}
|
||||||
interface LocalParticipantWithTrackPublications extends Pick<LocalParticipant, 'trackPublications'> {}
|
interface LocalParticipantWithTrackPublications extends Pick<LocalParticipant, 'trackPublications'> {}
|
||||||
interface LocalParticipantWithUnpublish extends LocalParticipantWithTrackPublications {
|
interface LocalParticipantWithUnpublish extends LocalParticipantWithTrackPublications {
|
||||||
unpublishTrack: LocalParticipant['unpublishTrack'];
|
unpublishTrack: LocalParticipant['unpublishTrack'];
|
||||||
}
|
}
|
||||||
|
interface LocalParticipantWithUnpublishEvents extends Pick<LocalParticipant, 'on' | 'off'> {}
|
||||||
|
|
||||||
|
const republishedCaptureOwners = new WeakMap<MediaStreamTrack, LocalTrack>();
|
||||||
|
|
||||||
export type LocalMediaPublicationSlot = 'camera' | 'screen_share';
|
export type LocalMediaPublicationSlot = 'camera' | 'screen_share';
|
||||||
|
|
||||||
@@ -246,3 +250,20 @@ export function selectLocalMediaPublicationsForConnectionRepublish(
|
|||||||
}
|
}
|
||||||
return selected;
|
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,
|
releaseE2EEWorker,
|
||||||
} from '@app/features/voice/engine/VoiceE2EEKeyProvider';
|
} from '@app/features/voice/engine/VoiceE2EEKeyProvider';
|
||||||
import {getSharedVoiceAudioContext} from '@app/features/voice/engine/VoiceSharedAudioContext';
|
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 {
|
import {
|
||||||
assertDisconnectReason,
|
assertDisconnectReason,
|
||||||
assertNonEmptyString,
|
assertNonEmptyString,
|
||||||
@@ -819,6 +822,7 @@ export class VoiceEngineV2AppConnectionHostAdapter extends Store {
|
|||||||
name: publication.trackName,
|
name: publication.trackName,
|
||||||
};
|
};
|
||||||
const republished = await newParticipant.publishTrack(track.mediaStreamTrack, publishOptions);
|
const republished = await newParticipant.publishTrack(track.mediaStreamTrack, publishOptions);
|
||||||
|
releaseCaptureWithRepublishedTrack(newParticipant, republished, track);
|
||||||
const violation = publishOptions.videoCodec
|
const violation = publishOptions.videoCodec
|
||||||
? findVideoPublishCodecPolicyViolation(publishOptions.videoCodec, republished.options?.videoCodec)
|
? findVideoPublishCodecPolicyViolation(publishOptions.videoCodec, republished.options?.videoCodec)
|
||||||
: null;
|
: null;
|
||||||
@@ -1096,12 +1100,10 @@ export class VoiceEngineV2AppConnectionHostAdapter extends Store {
|
|||||||
private disconnectPreviousRoom(previousRoom: Room | null, stopTracks = true): void {
|
private disconnectPreviousRoom(previousRoom: Room | null, stopTracks = true): void {
|
||||||
if (!previousRoom) return;
|
if (!previousRoom) return;
|
||||||
try {
|
try {
|
||||||
if (previousRoom.state === 'connected') {
|
const tracksToStop = stopTracks ? [] : this.getPreviousRoomNonScreenShareTracks(previousRoom);
|
||||||
const tracksToStop = stopTracks ? [] : this.getPreviousRoomNonScreenShareTracks(previousRoom);
|
previousRoom.disconnect(stopTracks);
|
||||||
previousRoom.disconnect(stopTracks);
|
this.stopPreviousRoomTracks(tracksToStop);
|
||||||
this.stopPreviousRoomTracks(tracksToStop);
|
logger.debug('Previous room disconnected', {stopTracks});
|
||||||
logger.debug('Previous room disconnected', {stopTracks});
|
|
||||||
}
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.warn('Failed to disconnect previous room', error);
|
logger.warn('Failed to disconnect previous room', error);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in new issue
Block a user