fix(voice): route media on evidence and switch devices without dropping the call
Outgoing voice was gated on the observer's roster copy of the remote user's voice state, which is signaling gossip. The signal server broadcasts `user_left` for any socket it declares dead, so a suspended laptop or a flaky hop wiped that copy and the observer detached its microphone from a peer that never left the channel - a silent member with no way back through the UI. `decideVoicePathRouting` now closes a path only on positive evidence: we left voice, the peer itself reported another channel or none, or the connection is gone. Missing gossip holds an established path instead. Opening still needs confirmation, so a guess never starts sending; the same rule gates playback, camera video, and the microphone a new connection puts in its first offer. Peers are also asked for their voice state when a connection or data channel comes up, so a rebuilt path re-confirms itself. Alongside it, the microphone can be switched mid-call: capture moves to a device service and rules, the live track is swapped with `replaceTrack` so the session is never renegotiated, and the speaking indicator follows the new stream.
This commit is contained in:
+8
@@ -81,6 +81,10 @@ export class VoiceConnectionFacade {
|
||||
await this.realtime.setLocalStream(stream);
|
||||
}
|
||||
|
||||
async switchInputDevice(deviceId: string): Promise<MediaStream | null> {
|
||||
return await this.realtime.switchInputDevice(deviceId);
|
||||
}
|
||||
|
||||
toggleMute(muted?: boolean): void {
|
||||
this.realtime.toggleMute(muted);
|
||||
}
|
||||
@@ -120,4 +124,8 @@ export class VoiceConnectionFacade {
|
||||
syncOutgoingVoiceRouting(allowedPeerIds: string[]): void {
|
||||
this.realtime.syncOutgoingVoiceRouting(allowedPeerIds);
|
||||
}
|
||||
|
||||
mayHearPeerVoice(peerId: string, hasEstablishedPlayback: boolean): boolean {
|
||||
return this.realtime.mayHearPeerVoice(peerId, hasEstablishedPlayback);
|
||||
}
|
||||
}
|
||||
|
||||
+20
-1
@@ -27,7 +27,7 @@ import {
|
||||
import { Subscription } from 'rxjs';
|
||||
import { VoiceConnectionFacade } from '../facades/voice-connection.facade';
|
||||
import { DebuggingService } from '../../../../core/services/debugging.service';
|
||||
/* eslint-disable @typescript-eslint/member-ordering, @typescript-eslint/prefer-for-of, max-statements-per-line */
|
||||
/* eslint-disable @typescript-eslint/prefer-for-of, max-statements-per-line */
|
||||
|
||||
const SPEAKING_THRESHOLD = 0.015;
|
||||
const SILENT_FRAME_GRACE = 8;
|
||||
@@ -50,6 +50,8 @@ export class VoiceActivityService implements OnDestroy {
|
||||
private readonly debugging = inject(DebuggingService);
|
||||
|
||||
private readonly tracked = new Map<string, TrackedStream>();
|
||||
/** The id the local microphone is tracked under, so it can follow a device switch. */
|
||||
private localMicUserId: string | null = null;
|
||||
private animFrameId: number | null = null;
|
||||
private readonly subs: Subscription[] = [];
|
||||
private readonly _speakingMap = signal<ReadonlyMap<string, boolean>>(new Map());
|
||||
@@ -84,13 +86,30 @@ export class VoiceActivityService implements OnDestroy {
|
||||
}
|
||||
|
||||
trackLocalMic(userId: string, stream: MediaStream): void {
|
||||
this.localMicUserId = userId;
|
||||
this.trackStream(userId, stream);
|
||||
}
|
||||
|
||||
untrackLocalMic(userId: string): void {
|
||||
if (this.localMicUserId === userId) {
|
||||
this.localMicUserId = null;
|
||||
}
|
||||
|
||||
this.untrackStream(userId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Point the local speaking indicator at a replacement microphone stream,
|
||||
* so switching devices mid-call does not leave it watching a dead track.
|
||||
*/
|
||||
refreshLocalMicStream(stream: MediaStream): void {
|
||||
if (!this.localMicUserId) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.trackStream(this.localMicUserId, stream);
|
||||
}
|
||||
|
||||
isSpeaking(userId: string): Signal<boolean> {
|
||||
const entry = this.tracked.get(userId);
|
||||
|
||||
|
||||
+30
-2
@@ -13,10 +13,12 @@ import { VoiceConnectionFacade } from '../facades/voice-connection.facade';
|
||||
import { VoicePlaybackService } from './voice-playback.service';
|
||||
|
||||
let audioContextCount = 0;
|
||||
let createdGainNodes: { gain: { value: number } }[] = [];
|
||||
|
||||
describe('VoicePlaybackService', () => {
|
||||
beforeEach(() => {
|
||||
audioContextCount = 0;
|
||||
createdGainNodes = [];
|
||||
installAudioDomMocks();
|
||||
installLocalStorageMock();
|
||||
});
|
||||
@@ -94,6 +96,27 @@ describe('VoicePlaybackService', () => {
|
||||
expect(audioContextCount).toBe(1);
|
||||
expect(context.service.getUserVolume('peer-1')).toBe(100);
|
||||
});
|
||||
|
||||
// The roster is gossip. A peer we already receive audio from stays audible unless the
|
||||
// voice session has positive evidence it left, otherwise a dropped socket silences a
|
||||
// member who never went anywhere.
|
||||
it('keeps a peer audible while the voice session holds the path open', () => {
|
||||
const context = createServiceContext({ isVoiceConnected: true });
|
||||
|
||||
context.service.handleRemoteStream('peer-1', createMockAudioStream(['track-a']), connectedOptions());
|
||||
|
||||
expect(context.voiceConnection.mayHearPeerVoice).toHaveBeenCalledWith('peer-1', true);
|
||||
expect(createdGainNodes.at(-1)?.gain.value).toBe(1);
|
||||
});
|
||||
|
||||
it('mutes a peer the voice session reports as gone from our channel', () => {
|
||||
const context = createServiceContext({ isVoiceConnected: true });
|
||||
|
||||
context.voiceConnection.mayHearPeerVoice.mockReturnValue(false);
|
||||
context.service.handleRemoteStream('peer-1', createMockAudioStream(['track-a']), connectedOptions());
|
||||
|
||||
expect(createdGainNodes.at(-1)?.gain.value).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
interface ServiceContext {
|
||||
@@ -106,6 +129,7 @@ interface ServiceContext {
|
||||
getRemoteVoiceStream: ReturnType<typeof vi.fn>;
|
||||
getConnectedPeers: ReturnType<typeof vi.fn>;
|
||||
syncOutgoingVoiceRouting: ReturnType<typeof vi.fn>;
|
||||
mayHearPeerVoice: ReturnType<typeof vi.fn>;
|
||||
};
|
||||
remoteStream$: Subject<{ peerId: string; stream: MediaStream }>;
|
||||
}
|
||||
@@ -119,7 +143,8 @@ function createServiceContext(options: { isVoiceConnected?: boolean } = {}): Ser
|
||||
onPeerDisconnected: new Subject<string>(),
|
||||
getRemoteVoiceStream: vi.fn(() => null),
|
||||
getConnectedPeers: vi.fn(() => []),
|
||||
syncOutgoingVoiceRouting: vi.fn()
|
||||
syncOutgoingVoiceRouting: vi.fn(),
|
||||
mayHearPeerVoice: vi.fn(() => true)
|
||||
};
|
||||
const screenShare = {
|
||||
isScreenShareRemotePlaybackSuppressed: signal(false),
|
||||
@@ -237,10 +262,13 @@ function installAudioDomMocks(): void {
|
||||
}
|
||||
|
||||
createGain() {
|
||||
return {
|
||||
const gainNode = {
|
||||
gain: { value: 0 },
|
||||
connect: vi.fn()
|
||||
};
|
||||
|
||||
createdGainNodes.push(gainNode);
|
||||
return gainNode;
|
||||
}
|
||||
|
||||
createMediaStreamSource() {
|
||||
|
||||
+12
-18
@@ -7,7 +7,6 @@ import { Store } from '@ngrx/store';
|
||||
import { STORAGE_KEY_USER_VOLUMES } from '../../../../core/constants';
|
||||
import { jsonStorage } from '../../../../infrastructure/persistence/json-storage.service';
|
||||
import { ScreenShareFacade } from '../../../../domains/screen-share';
|
||||
import { User } from '../../../../shared-kernel';
|
||||
import { selectAllUsers, selectCurrentUser } from '../../../../store/users/users.selectors';
|
||||
import { VoiceConnectionFacade } from '../facades/voice-connection.facade';
|
||||
|
||||
@@ -331,7 +330,7 @@ export class VoicePlaybackService {
|
||||
if (!pipeline)
|
||||
return;
|
||||
|
||||
if (this.deafened || this.captureEchoSuppressed || this.isUserMuted(peerId) || !this.isPeerInCurrentVoiceRoom(peerId)) {
|
||||
if (this.deafened || this.captureEchoSuppressed || this.isUserMuted(peerId) || !this.mayHearPeer(peerId)) {
|
||||
pipeline.gainNode.gain.value = 0;
|
||||
return;
|
||||
}
|
||||
@@ -346,22 +345,17 @@ export class VoicePlaybackService {
|
||||
this.peerPipelines.forEach((_pipeline, peerId) => this.applyGain(peerId));
|
||||
}
|
||||
|
||||
private isPeerInCurrentVoiceRoom(peerId: string): boolean {
|
||||
const localVoiceState = this.currentUser()?.voiceState;
|
||||
|
||||
if (!localVoiceState?.isConnected || !localVoiceState.roomId || !localVoiceState.serverId) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const remoteVoiceState = this.findUserForPeer(peerId)?.voiceState;
|
||||
|
||||
return !!remoteVoiceState?.isConnected
|
||||
&& remoteVoiceState.roomId === localVoiceState.roomId
|
||||
&& remoteVoiceState.serverId === localVoiceState.serverId;
|
||||
}
|
||||
|
||||
private findUserForPeer(peerId: string): User | undefined {
|
||||
return this.allUsers().find((user) => user.id === peerId || user.oderId === peerId || user.peerId === peerId);
|
||||
/**
|
||||
* Whether this peer's audio may be audible.
|
||||
*
|
||||
* The roster is gossip: the signal server broadcasts `user_left` for any socket it
|
||||
* declares dead, which used to mute a peer still sitting in our channel. Playback
|
||||
* therefore asks the voice session, which weighs the roster against what the peer
|
||||
* itself reported over its data channel, and holds a stream we already receive when
|
||||
* neither confirms nor denies.
|
||||
*/
|
||||
private mayHearPeer(peerId: string): boolean {
|
||||
return this.voiceConnection.mayHearPeerVoice(peerId, this.rawRemoteStreams.has(peerId));
|
||||
}
|
||||
|
||||
private syncOutgoingVoiceRouting(): void {
|
||||
|
||||
Reference in New Issue
Block a user