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:
+171
@@ -0,0 +1,171 @@
|
||||
import {
|
||||
afterEach,
|
||||
beforeEach,
|
||||
describe,
|
||||
expect,
|
||||
it,
|
||||
vi
|
||||
} from 'vitest';
|
||||
import { Subject } from 'rxjs';
|
||||
import { createPeerConnection } from './create-peer-connection';
|
||||
import {
|
||||
ConnectionLifecycleHandlers,
|
||||
PeerConnectionManagerContext,
|
||||
createPeerConnectionManagerState
|
||||
} from '../shared';
|
||||
|
||||
interface FakeSender {
|
||||
track: MediaStreamTrack | null;
|
||||
replaceTrack: ReturnType<typeof vi.fn>;
|
||||
setStreams: ReturnType<typeof vi.fn>;
|
||||
}
|
||||
|
||||
interface FakeTransceiver {
|
||||
sender: FakeSender;
|
||||
receiver: { track: { kind: string } };
|
||||
direction: string;
|
||||
}
|
||||
|
||||
interface FakeConnection {
|
||||
transceivers: FakeTransceiver[];
|
||||
addTrack: ReturnType<typeof vi.fn>;
|
||||
}
|
||||
|
||||
/**
|
||||
* A peer connection carries chat and presence for everyone in the server, not only the
|
||||
* people in our voice channel, and the initiator attaches the microphone before the very
|
||||
* first offer. Being in voice therefore used to be enough to start sending the microphone
|
||||
* to whoever we peered with next - and because a routing pass reads an attached track as
|
||||
* an established path, it then held that path open instead of closing it.
|
||||
*/
|
||||
describe('createPeerConnection microphone gate', () => {
|
||||
let connection: FakeConnection;
|
||||
let micTrack: MediaStreamTrack;
|
||||
let micStream: MediaStream;
|
||||
|
||||
beforeEach(() => {
|
||||
connection = createFakeConnection();
|
||||
({ micStream, micTrack } = createFakeMicStream());
|
||||
|
||||
vi.stubGlobal('RTCPeerConnection', vi.fn(function FakeRTCPeerConnection() {
|
||||
return connection;
|
||||
}));
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
it('leaves the microphone off the first offer to a peer nothing confirms', () => {
|
||||
const context = createContext({ micStream, mayOpenVoicePath: false });
|
||||
const peerData = createPeerConnection(context, 'peer-1', true, createHandlers());
|
||||
|
||||
expect(context.callbacks.mayOpenVoicePathToPeer).toHaveBeenCalledWith('peer-1');
|
||||
expect(peerData.audioSender?.track ?? null).toBeNull();
|
||||
expect(connection.addTrack).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('puts the microphone on the first offer to a confirmed channel member', () => {
|
||||
const context = createContext({ micStream, mayOpenVoicePath: true });
|
||||
const peerData = createPeerConnection(context, 'peer-1', true, createHandlers());
|
||||
|
||||
expect(peerData.audioSender?.track).toBe(micTrack);
|
||||
});
|
||||
});
|
||||
|
||||
function createFakeConnection(): FakeConnection {
|
||||
const transceivers: FakeTransceiver[] = [];
|
||||
const fake = {
|
||||
connectionState: 'new',
|
||||
iceConnectionState: 'new',
|
||||
signalingState: 'stable',
|
||||
transceivers,
|
||||
addTrack: vi.fn(),
|
||||
addTransceiver: vi.fn((kind: string, init?: { direction?: string }) => {
|
||||
const transceiver: FakeTransceiver = {
|
||||
sender: {
|
||||
track: null,
|
||||
replaceTrack: vi.fn(async (track: MediaStreamTrack | null) => {
|
||||
transceiver.sender.track = track;
|
||||
}),
|
||||
setStreams: vi.fn()
|
||||
},
|
||||
receiver: { track: { kind } },
|
||||
direction: init?.direction ?? 'sendrecv'
|
||||
};
|
||||
|
||||
transceivers.push(transceiver);
|
||||
return transceiver;
|
||||
}),
|
||||
createDataChannel: vi.fn(() => ({ label: 'data', readyState: 'connecting' })),
|
||||
getSenders: () => transceivers.map((transceiver) => transceiver.sender),
|
||||
getTransceivers: () => transceivers
|
||||
};
|
||||
|
||||
return fake as unknown as FakeConnection;
|
||||
}
|
||||
|
||||
function createFakeMicStream(): { micStream: MediaStream; micTrack: MediaStreamTrack } {
|
||||
const micTrack = { kind: 'audio', id: 'mic-track' } as unknown as MediaStreamTrack;
|
||||
const micStream = {
|
||||
id: 'mic-stream',
|
||||
getTracks: () => [micTrack],
|
||||
getAudioTracks: () => [micTrack],
|
||||
getVideoTracks: () => []
|
||||
} as unknown as MediaStream;
|
||||
|
||||
return { micStream, micTrack };
|
||||
}
|
||||
|
||||
function createContext(options: {
|
||||
micStream: MediaStream;
|
||||
mayOpenVoicePath: boolean;
|
||||
}): PeerConnectionManagerContext {
|
||||
return {
|
||||
logger: {
|
||||
error: vi.fn(),
|
||||
info: vi.fn(),
|
||||
logStream: vi.fn(),
|
||||
warn: vi.fn()
|
||||
} as unknown as PeerConnectionManagerContext['logger'],
|
||||
callbacks: {
|
||||
getIceServers: vi.fn(() => []),
|
||||
getIdentifyCredentials: vi.fn(() => null),
|
||||
getIdentifyCredentialsForPeer: vi.fn(() => null),
|
||||
getLocalMediaStream: vi.fn(() => options.micStream),
|
||||
getLocalPeerId: vi.fn(() => 'local-peer'),
|
||||
getVoiceStateSnapshot: vi.fn(() => ({
|
||||
isConnected: true,
|
||||
isMuted: false,
|
||||
isDeafened: false,
|
||||
isScreenSharing: false,
|
||||
roomId: 'voice-1',
|
||||
serverId: 'server-1'
|
||||
})),
|
||||
isCameraEnabled: vi.fn(() => false),
|
||||
isScreenSharingActive: vi.fn(() => false),
|
||||
isSignalingConnected: vi.fn(() => true),
|
||||
mayOpenVoicePathToPeer: vi.fn(() => options.mayOpenVoicePath),
|
||||
sendRawMessage: vi.fn()
|
||||
},
|
||||
state: {
|
||||
...createPeerConnectionManagerState(),
|
||||
peerConnected$: new Subject<string>()
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
function createHandlers(): ConnectionLifecycleHandlers {
|
||||
return {
|
||||
addToConnectedPeers: vi.fn(),
|
||||
clearPeerDisconnectGraceTimer: vi.fn(),
|
||||
clearPeerReconnectTimer: vi.fn(),
|
||||
handleRemoteTrack: vi.fn(),
|
||||
removePeer: vi.fn(),
|
||||
requestVoiceStateFromPeer: vi.fn(),
|
||||
schedulePeerDisconnectRecovery: vi.fn(),
|
||||
schedulePeerReconnect: vi.fn(),
|
||||
setupDataChannel: vi.fn(),
|
||||
trackDisconnectedPeer: vi.fn()
|
||||
};
|
||||
}
|
||||
+13
@@ -188,9 +188,22 @@ export function createPeerConnection(
|
||||
const localStream = callbacks.getLocalMediaStream();
|
||||
|
||||
if (localStream && isInitiator) {
|
||||
const mayCarryVoice = callbacks.mayOpenVoicePathToPeer(remotePeerId);
|
||||
|
||||
logger.logStream(`localStream->${remotePeerId}`, localStream);
|
||||
|
||||
localStream.getTracks().forEach((track) => {
|
||||
// Being in voice ourselves is not a reason to send the microphone to whoever we
|
||||
// happen to peer with next: this connection also carries chat and presence for
|
||||
// people who are not in the channel. Routing attaches it once something confirms.
|
||||
if (track.kind === TRACK_KIND_AUDIO && !mayCarryVoice) {
|
||||
logger.info('Leaving the microphone off the first offer for an unconfirmed voice peer', {
|
||||
remotePeerId
|
||||
});
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if (track.kind === TRACK_KIND_AUDIO && peerData.audioSender) {
|
||||
if (typeof peerData.audioSender.setStreams === 'function') {
|
||||
peerData.audioSender.setStreams(localStream);
|
||||
|
||||
+3
-2
@@ -5,6 +5,7 @@ import {
|
||||
TRACK_KIND_VIDEO,
|
||||
TRANSCEIVER_SEND_RECV
|
||||
} from '../../realtime.constants';
|
||||
import { isPoliteOnOfferCollision } from '../../peer-role.rules';
|
||||
import {
|
||||
NegotiationHandlers,
|
||||
PeerConnectionManagerContext,
|
||||
@@ -115,8 +116,8 @@ async function resolveOfferCollision(
|
||||
if (!hasCollision)
|
||||
return true;
|
||||
|
||||
const localOderId = callbacks.getIdentifyCredentials()?.oderId ?? null;
|
||||
const isPolite = !localOderId || localOderId > fromUserId;
|
||||
const localOderId = callbacks.getIdentifyCredentialsForPeer(fromUserId)?.oderId ?? null;
|
||||
const isPolite = isPoliteOnOfferCollision(localOderId, fromUserId);
|
||||
|
||||
if (!isPolite) {
|
||||
logger.info('Ignoring colliding offer (impolite side)', { fromUserId, localOderId });
|
||||
|
||||
+27
@@ -26,6 +26,29 @@ describe('data channel lifecycle', () => {
|
||||
expect(handlers.scheduleDataChannelRecovery).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('announces the actor id the peer knows us by on its own signal server', () => {
|
||||
const context = createContext();
|
||||
const handlers = createHandlers();
|
||||
const channel = createDataChannel(DATA_CHANNEL_STATE_OPEN);
|
||||
|
||||
setupDataChannel(context, channel, 'peer-a', handlers);
|
||||
channel.onopen?.(new Event('open'));
|
||||
|
||||
expect(channel.send).toHaveBeenCalledWith(expect.stringContaining('"oderId":"local-user-foreign"'));
|
||||
expect(channel.send).not.toHaveBeenCalledWith(expect.stringContaining('"oderId":"local-user"'));
|
||||
});
|
||||
|
||||
it('falls back to the home identity when the peer signal route is unknown', () => {
|
||||
const context = createContext();
|
||||
const handlers = createHandlers();
|
||||
const channel = createDataChannel(DATA_CHANNEL_STATE_OPEN);
|
||||
|
||||
setupDataChannel(context, channel, 'peer-unrouted', handlers);
|
||||
channel.onopen?.(new Event('open'));
|
||||
|
||||
expect(channel.send).toHaveBeenCalledWith(expect.stringContaining('"oderId":"local-user"'));
|
||||
});
|
||||
|
||||
it('requests a voice-state resync on a non-fatal data channel error', () => {
|
||||
const context = createContext();
|
||||
const handlers = createHandlers();
|
||||
@@ -74,8 +97,12 @@ function createContext(): PeerConnectionManagerContext {
|
||||
callbacks: {
|
||||
getIceServers: vi.fn(() => []),
|
||||
getIdentifyCredentials: vi.fn(() => ({ oderId: 'local-user', token: 'session-token', displayName: 'Local User' })),
|
||||
getIdentifyCredentialsForPeer: vi.fn((peerId: string) => peerId === 'peer-a'
|
||||
? { displayName: 'Local User', oderId: 'local-user-foreign', token: 'foreign-token' }
|
||||
: null),
|
||||
getLocalMediaStream: vi.fn(() => null),
|
||||
getLocalPeerId: vi.fn(() => 'local-peer'),
|
||||
mayOpenVoicePathToPeer: vi.fn(() => true),
|
||||
getVoiceStateSnapshot: vi.fn(() => ({
|
||||
isConnected: true,
|
||||
isMuted: false,
|
||||
|
||||
+57
-29
@@ -21,6 +21,11 @@ type PeerMessage = Record<string, unknown> & {
|
||||
ts?: number;
|
||||
};
|
||||
|
||||
export interface PeerScopedIdentity {
|
||||
oderId: string;
|
||||
displayName: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Wire open/close/error/message handlers onto a data channel.
|
||||
*/
|
||||
@@ -239,18 +244,22 @@ export function broadcastMessage(
|
||||
|
||||
/**
|
||||
* Send a ChatEvent to a specific peer's data channel.
|
||||
*
|
||||
* Returns whether the payload actually reached the channel. Callers that
|
||||
* reason about peer state - sync rounds, call rings - must not treat a
|
||||
* closed channel or a failed send as a delivery.
|
||||
*/
|
||||
export function sendToPeer(
|
||||
context: PeerConnectionManagerContext,
|
||||
peerId: string,
|
||||
event: object
|
||||
): void {
|
||||
): boolean {
|
||||
const { logger, state } = context;
|
||||
const peerData = state.activePeerConnections.get(peerId);
|
||||
|
||||
if (!peerData?.dataChannel || peerData.dataChannel.readyState !== DATA_CHANNEL_STATE_OPEN) {
|
||||
logger.warn('Peer not connected - cannot send', { peerId });
|
||||
return;
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
@@ -260,6 +269,8 @@ export function sendToPeer(
|
||||
recordDebugNetworkDataChannelPayload(peerId, event as PeerMessage, 'outbound');
|
||||
|
||||
logDataChannelTraffic(context, peerData.dataChannel, peerId, 'outbound', rawPayload, event as PeerMessage);
|
||||
|
||||
return true;
|
||||
} catch (error) {
|
||||
logger.error('[data-channel] Failed to send message to peer', error, {
|
||||
bufferedAmount: peerData.dataChannel.bufferedAmount,
|
||||
@@ -268,6 +279,8 @@ export function sendToPeer(
|
||||
peerId,
|
||||
readyState: peerData.dataChannel.readyState
|
||||
});
|
||||
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -339,9 +352,7 @@ export function sendCurrentStatesToPeer(
|
||||
peerId: string
|
||||
): void {
|
||||
const { callbacks } = context;
|
||||
const credentials = callbacks.getIdentifyCredentials();
|
||||
const oderId = credentials?.oderId || callbacks.getLocalPeerId();
|
||||
const displayName = credentials?.displayName || DEFAULT_DISPLAY_NAME;
|
||||
const { displayName, oderId } = resolvePeerScopedIdentity(context, peerId);
|
||||
const voiceState = callbacks.getVoiceStateSnapshot();
|
||||
|
||||
sendToPeer(context, peerId, {
|
||||
@@ -382,9 +393,7 @@ export function sendCurrentStatesToChannel(
|
||||
return;
|
||||
}
|
||||
|
||||
const credentials = callbacks.getIdentifyCredentials();
|
||||
const oderId = credentials?.oderId || callbacks.getLocalPeerId();
|
||||
const displayName = credentials?.displayName || DEFAULT_DISPLAY_NAME;
|
||||
const { displayName, oderId } = resolvePeerScopedIdentity(context, remotePeerId);
|
||||
const voiceState = callbacks.getVoiceStateSnapshot();
|
||||
|
||||
try {
|
||||
@@ -431,31 +440,50 @@ export function sendCurrentStatesToChannel(
|
||||
|
||||
/** Broadcast the current voice, camera, and screen-share states to all connected peers. */
|
||||
export function broadcastCurrentStates(context: PeerConnectionManagerContext): void {
|
||||
forEachOpenPeerChannel(context, (peerId) => sendCurrentStatesToPeer(context, peerId));
|
||||
}
|
||||
|
||||
/**
|
||||
* Broadcast a self-identifying payload, rebuilt per peer so each one receives the actor
|
||||
* id it knows us by on its own signal server.
|
||||
*/
|
||||
export function broadcastIdentityScopedMessage(
|
||||
context: PeerConnectionManagerContext,
|
||||
buildEvent: (identity: PeerScopedIdentity) => object
|
||||
): void {
|
||||
forEachOpenPeerChannel(context, (peerId) => {
|
||||
sendToPeer(context, peerId, buildEvent(resolvePeerScopedIdentity(context, peerId)));
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* The identity to announce to one peer. Voice, camera, and screen-share payloads are keyed
|
||||
* by the id that peer knows us by, which is our actor id on the signal server routing them
|
||||
* - not our home id.
|
||||
*/
|
||||
function resolvePeerScopedIdentity(
|
||||
context: PeerConnectionManagerContext,
|
||||
peerId: string
|
||||
): PeerScopedIdentity {
|
||||
const { callbacks } = context;
|
||||
const credentials = callbacks.getIdentifyCredentials();
|
||||
const oderId = credentials?.oderId || callbacks.getLocalPeerId();
|
||||
const displayName = credentials?.displayName || DEFAULT_DISPLAY_NAME;
|
||||
const voiceState = callbacks.getVoiceStateSnapshot();
|
||||
const credentials = callbacks.getIdentifyCredentialsForPeer(peerId) ?? callbacks.getIdentifyCredentials();
|
||||
|
||||
broadcastMessage(context, {
|
||||
type: P2P_TYPE_VOICE_STATE,
|
||||
oderId,
|
||||
displayName,
|
||||
voiceState
|
||||
});
|
||||
return {
|
||||
displayName: credentials?.displayName || DEFAULT_DISPLAY_NAME,
|
||||
oderId: credentials?.oderId || callbacks.getLocalPeerId()
|
||||
};
|
||||
}
|
||||
|
||||
broadcastMessage(context, {
|
||||
type: P2P_TYPE_SCREEN_STATE,
|
||||
oderId,
|
||||
displayName,
|
||||
isScreenSharing: callbacks.isScreenSharingActive()
|
||||
});
|
||||
function forEachOpenPeerChannel(
|
||||
context: PeerConnectionManagerContext,
|
||||
send: (peerId: string) => void
|
||||
): void {
|
||||
context.state.activePeerConnections.forEach((peerData, peerId) => {
|
||||
if (peerData.dataChannel?.readyState !== DATA_CHANNEL_STATE_OPEN) {
|
||||
return;
|
||||
}
|
||||
|
||||
broadcastMessage(context, {
|
||||
type: P2P_TYPE_CAMERA_STATE,
|
||||
oderId,
|
||||
displayName,
|
||||
isCameraEnabled: callbacks.isCameraEnabled()
|
||||
send(peerId);
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
DisconnectedPeerEntry,
|
||||
IdentifyCredentials,
|
||||
PeerData,
|
||||
PeerRecoveryStatusEvent,
|
||||
VoiceStateSnapshot
|
||||
} from '../realtime.types';
|
||||
|
||||
@@ -17,12 +18,24 @@ export interface PeerConnectionCallbacks {
|
||||
sendRawMessage(msg: Record<string, unknown>): void;
|
||||
/** Get the current local media stream (mic audio). */
|
||||
getLocalMediaStream(): MediaStream | null;
|
||||
/**
|
||||
* Whether a peer connection being built may carry the local microphone in its first
|
||||
* offer, or whether nothing yet confirms the peer is in our voice channel.
|
||||
*/
|
||||
mayOpenVoicePathToPeer(peerId: string): boolean;
|
||||
/** Whether signaling is currently connected. */
|
||||
isSignalingConnected(): boolean;
|
||||
/** Returns the current voice/screen state snapshot for broadcasting. */
|
||||
getVoiceStateSnapshot(): VoiceStateSnapshot;
|
||||
/** Returns the identify credentials (oderId + displayName). */
|
||||
getIdentifyCredentials(): IdentifyCredentials | null;
|
||||
/**
|
||||
* Returns the identify credentials in the identity space of the signal server that
|
||||
* routes this peer, or null when that peer's signal route is unknown. Peer ids come
|
||||
* from a per-signal roster, so role election and self-identifying payloads must use
|
||||
* this rather than the home credential.
|
||||
*/
|
||||
getIdentifyCredentialsForPeer(peerId: string): IdentifyCredentials | null;
|
||||
/** Returns the local peer ID. */
|
||||
getLocalPeerId(): string;
|
||||
/** Whether screen sharing is active. */
|
||||
@@ -50,6 +63,7 @@ export interface PeerConnectionManagerState {
|
||||
peerNegotiationQueue: Map<string, Promise<void>>;
|
||||
peerConnected$: Subject<string>;
|
||||
peerDisconnected$: Subject<string>;
|
||||
peerRecoveryStatus$: Subject<PeerRecoveryStatusEvent>;
|
||||
remoteStream$: Subject<{ peerId: string; stream: MediaStream }>;
|
||||
messageReceived$: Subject<ChatEvent>;
|
||||
connectedPeersChanged$: Subject<string[]>;
|
||||
@@ -113,6 +127,7 @@ export function createPeerConnectionManagerState(): PeerConnectionManagerState {
|
||||
peerNegotiationQueue: new Map<string, Promise<void>>(),
|
||||
peerConnected$: new Subject<string>(),
|
||||
peerDisconnected$: new Subject<string>(),
|
||||
peerRecoveryStatus$: new Subject<PeerRecoveryStatusEvent>(),
|
||||
remoteStream$: new Subject<{ peerId: string; stream: MediaStream }>(),
|
||||
messageReceived$: new Subject<ChatEvent>(),
|
||||
connectedPeersChanged$: new Subject<string[]>(),
|
||||
|
||||
Reference in New Issue
Block a user