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:
@@ -0,0 +1,547 @@
|
||||
import {
|
||||
afterEach,
|
||||
beforeEach,
|
||||
describe,
|
||||
expect,
|
||||
it,
|
||||
vi
|
||||
} from 'vitest';
|
||||
import { MediaManager, MediaManagerCallbacks } from './media.manager';
|
||||
import { PeerData } from '../realtime.types';
|
||||
|
||||
interface FakeTrack {
|
||||
kind: string;
|
||||
enabled: boolean;
|
||||
readyState: string;
|
||||
id: string;
|
||||
stop: ReturnType<typeof vi.fn>;
|
||||
getSettings(): { deviceId: string };
|
||||
}
|
||||
|
||||
interface FakeSender {
|
||||
track: FakeTrack | null;
|
||||
replaceTrack: ReturnType<typeof vi.fn>;
|
||||
setStreams: ReturnType<typeof vi.fn>;
|
||||
}
|
||||
|
||||
interface FakeTransceiver {
|
||||
sender: FakeSender;
|
||||
receiver: { track: { kind: string } | null };
|
||||
direction: string;
|
||||
}
|
||||
|
||||
interface FakePeer {
|
||||
peerData: PeerData;
|
||||
sender: FakeSender;
|
||||
addTransceiver: ReturnType<typeof vi.fn>;
|
||||
removeTrack: ReturnType<typeof vi.fn>;
|
||||
}
|
||||
|
||||
describe('MediaManager microphone switching', () => {
|
||||
let capturedConstraints: MediaStreamConstraints[];
|
||||
let getUserMedia: ReturnType<typeof vi.fn>;
|
||||
|
||||
beforeEach(() => {
|
||||
capturedConstraints = [];
|
||||
getUserMedia = vi.fn(async (constraints: MediaStreamConstraints) => {
|
||||
capturedConstraints.push(constraints);
|
||||
|
||||
const requestedDeviceId = readRequestedDeviceId(constraints);
|
||||
|
||||
return createFakeStream(`track-${requestedDeviceId || 'default'}-${capturedConstraints.length}`, requestedDeviceId);
|
||||
});
|
||||
|
||||
vi.stubGlobal('navigator', { mediaDevices: { getUserMedia } });
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
it('captures the preferred microphone when voice starts', async () => {
|
||||
const { manager } = await startVoice({ preferredInputDeviceId: 'headset' });
|
||||
|
||||
expect(readRequestedDeviceId(capturedConstraints[0])).toBe('headset');
|
||||
manager.destroy();
|
||||
});
|
||||
|
||||
it('swaps the live track into the existing peer sender without renegotiating', async () => {
|
||||
const { manager, peer, callbacks } = await startVoice();
|
||||
const previousTrack = peer.sender.track;
|
||||
|
||||
callbacks.renegotiate.mockClear();
|
||||
peer.sender.replaceTrack.mockClear();
|
||||
|
||||
await manager.switchInputDevice('headset');
|
||||
|
||||
expect(readRequestedDeviceId(capturedConstraints[1])).toBe('headset');
|
||||
expect(peer.sender.replaceTrack).toHaveBeenCalledTimes(1);
|
||||
expect(peer.sender.track).not.toBe(previousTrack);
|
||||
expect(callbacks.renegotiate).not.toHaveBeenCalled();
|
||||
expect(manager.getIsVoiceActive()).toBe(true);
|
||||
|
||||
manager.destroy();
|
||||
});
|
||||
|
||||
it('releases the previous microphone after the swap', async () => {
|
||||
const { manager, firstStreamTracks } = await startVoice();
|
||||
|
||||
await manager.switchInputDevice('headset');
|
||||
|
||||
expect(firstStreamTracks[0].stop).toHaveBeenCalled();
|
||||
manager.destroy();
|
||||
});
|
||||
|
||||
it('keeps the microphone muted across a swap', async () => {
|
||||
const { manager } = await startVoice();
|
||||
|
||||
manager.toggleMute(true);
|
||||
|
||||
await manager.switchInputDevice('headset');
|
||||
|
||||
expect(manager.getIsMicMuted()).toBe(true);
|
||||
expect(manager.getLocalStream()?.getAudioTracks()[0].enabled).toBe(false);
|
||||
|
||||
manager.destroy();
|
||||
});
|
||||
|
||||
it('renegotiates when the audio transceiver had to be created', async () => {
|
||||
const { manager, peer, callbacks } = createManager({ withExistingAudioTransceiver: false });
|
||||
|
||||
await manager.toggleNoiseReduction(false);
|
||||
await manager.enableVoice();
|
||||
|
||||
manager.setAllowedVoicePeerIds(['peer-1']);
|
||||
|
||||
expect(peer.addTransceiver).toHaveBeenCalled();
|
||||
expect(callbacks.renegotiate).toHaveBeenCalledWith('peer-1');
|
||||
|
||||
manager.destroy();
|
||||
});
|
||||
|
||||
it('only records the preference when voice is not active', async () => {
|
||||
const { manager, callbacks } = createManager();
|
||||
|
||||
await expect(manager.switchInputDevice('headset')).resolves.toBeNull();
|
||||
expect(getUserMedia).not.toHaveBeenCalled();
|
||||
expect(callbacks.renegotiate).not.toHaveBeenCalled();
|
||||
|
||||
await manager.enableVoice();
|
||||
|
||||
expect(readRequestedDeviceId(capturedConstraints[0])).toBe('headset');
|
||||
manager.destroy();
|
||||
});
|
||||
|
||||
// Requiring the device exactly is what makes the picker work, so the price is
|
||||
// that a vanished device rejects. Voice must survive that, not end.
|
||||
it('falls back to the system default when the saved device is gone', async () => {
|
||||
getUserMedia.mockImplementation(async (constraints: MediaStreamConstraints) => {
|
||||
capturedConstraints.push(constraints);
|
||||
|
||||
const requestedDeviceId = readRequestedDeviceId(constraints);
|
||||
|
||||
if (requestedDeviceId === 'unplugged-headset') {
|
||||
throw Object.assign(new Error('device gone'), { name: 'OverconstrainedError' });
|
||||
}
|
||||
|
||||
return createFakeStream(`track-${requestedDeviceId || 'default'}`, requestedDeviceId);
|
||||
});
|
||||
|
||||
const { manager } = createManager();
|
||||
|
||||
await manager.switchInputDevice('unplugged-headset');
|
||||
await expect(manager.enableVoice()).resolves.toBeDefined();
|
||||
|
||||
expect(readRequestedDeviceId(capturedConstraints[0])).toBe('unplugged-headset');
|
||||
expect(readRequestedDeviceId(capturedConstraints[1])).toBe('');
|
||||
expect(manager.getPreferredInputDeviceId()).toBe('');
|
||||
manager.destroy();
|
||||
});
|
||||
|
||||
it('does not open another microphone when permission was denied', async () => {
|
||||
getUserMedia.mockImplementation(async () => {
|
||||
throw Object.assign(new Error('denied'), { name: 'NotAllowedError' });
|
||||
});
|
||||
|
||||
const { manager } = createManager();
|
||||
|
||||
await manager.switchInputDevice('headset');
|
||||
await expect(manager.enableVoice()).rejects.toThrow('denied');
|
||||
expect(getUserMedia).toHaveBeenCalledTimes(1);
|
||||
manager.destroy();
|
||||
});
|
||||
});
|
||||
|
||||
describe('MediaManager outgoing voice routing', () => {
|
||||
beforeEach(() => {
|
||||
vi.stubGlobal('navigator', {
|
||||
mediaDevices: {
|
||||
getUserMedia: vi.fn(async () => createFakeStream('track-default', ''))
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
// The signal server broadcasts `user_left` for a socket it declared dead, which wipes
|
||||
// the roster copy of a peer that never left voice. Detaching there left one side of a
|
||||
// live call permanently silent, with no way back through the UI.
|
||||
it('keeps sending to a peer the roster forgot mid-call', async () => {
|
||||
const { manager, peer } = await startVoiceInChannel();
|
||||
|
||||
manager.setAllowedVoicePeerIds([]);
|
||||
|
||||
expect(peer.removeTrack).not.toHaveBeenCalled();
|
||||
expect(peer.sender.track).not.toBeNull();
|
||||
|
||||
manager.destroy();
|
||||
});
|
||||
|
||||
it('stops sending as soon as the peer itself reports leaving voice', async () => {
|
||||
const { manager, peer } = await startVoiceInChannel();
|
||||
|
||||
manager.setAllowedVoicePeerIds([]);
|
||||
manager.notePeerVoiceReport('peer-1', { isConnected: false });
|
||||
|
||||
expect(peer.removeTrack).toHaveBeenCalled();
|
||||
|
||||
manager.destroy();
|
||||
});
|
||||
|
||||
it('stops sending when the peer reports another voice channel', async () => {
|
||||
const { manager, peer } = await startVoiceInChannel();
|
||||
|
||||
manager.setAllowedVoicePeerIds([]);
|
||||
manager.notePeerVoiceReport('peer-1', {
|
||||
isConnected: true,
|
||||
roomId: 'voice-2',
|
||||
serverId: 'server-1'
|
||||
});
|
||||
|
||||
expect(peer.removeTrack).toHaveBeenCalled();
|
||||
|
||||
manager.destroy();
|
||||
});
|
||||
|
||||
// The fail-open half must never turn into "send the microphone on a hunch".
|
||||
it('never opens a voice path for a peer nothing confirmed', async () => {
|
||||
const { manager, peer } = createManager();
|
||||
|
||||
await manager.toggleNoiseReduction(false);
|
||||
await manager.enableVoice();
|
||||
manager.startVoiceHeartbeat('voice-1', 'server-1');
|
||||
|
||||
expect(peer.sender.track).toBeNull();
|
||||
|
||||
manager.destroy();
|
||||
});
|
||||
|
||||
it('opens a voice path when the peer reports our channel itself', async () => {
|
||||
const { manager, peer } = createManager();
|
||||
|
||||
await manager.toggleNoiseReduction(false);
|
||||
await manager.enableVoice();
|
||||
manager.startVoiceHeartbeat('voice-1', 'server-1');
|
||||
manager.notePeerVoiceReport('peer-1', {
|
||||
isConnected: true,
|
||||
roomId: 'voice-1',
|
||||
serverId: 'server-1'
|
||||
});
|
||||
|
||||
expect(peer.sender.track).not.toBeNull();
|
||||
|
||||
manager.destroy();
|
||||
});
|
||||
});
|
||||
|
||||
// A new peer connection attaches the microphone before its first offer, which spares a
|
||||
// confirmed channel member a second SDP exchange. That shortcut bypassed routing
|
||||
// entirely, so being in voice was enough to send the microphone to the next person we
|
||||
// peered with - and the attached track then read as an established path routing held.
|
||||
describe('MediaManager first-offer voice gate', () => {
|
||||
beforeEach(() => {
|
||||
vi.stubGlobal('navigator', {
|
||||
mediaDevices: {
|
||||
getUserMedia: vi.fn(async () => createFakeStream('track-default', ''))
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
it('keeps the microphone off a first offer to a peer nothing confirmed', async () => {
|
||||
const { manager } = createManager();
|
||||
|
||||
await manager.toggleNoiseReduction(false);
|
||||
await manager.enableVoice();
|
||||
manager.startVoiceHeartbeat('voice-1', 'server-1');
|
||||
|
||||
expect(manager.mayOpenVoicePathToPeer('peer-2')).toBe(false);
|
||||
|
||||
manager.destroy();
|
||||
});
|
||||
|
||||
it('puts the microphone on a first offer to a confirmed channel member', async () => {
|
||||
const { manager } = createManager();
|
||||
|
||||
await manager.toggleNoiseReduction(false);
|
||||
await manager.enableVoice();
|
||||
manager.startVoiceHeartbeat('voice-1', 'server-1');
|
||||
manager.setAllowedVoicePeerIds(['peer-2']);
|
||||
|
||||
expect(manager.mayOpenVoicePathToPeer('peer-2')).toBe(true);
|
||||
|
||||
manager.destroy();
|
||||
});
|
||||
|
||||
it('never puts the microphone on a first offer while we are not in voice', async () => {
|
||||
const { manager } = createManager();
|
||||
|
||||
manager.setAllowedVoicePeerIds(['peer-2']);
|
||||
|
||||
expect(manager.mayOpenVoicePathToPeer('peer-2')).toBe(false);
|
||||
|
||||
manager.destroy();
|
||||
});
|
||||
});
|
||||
|
||||
describe('MediaManager camera routing', () => {
|
||||
beforeEach(() => {
|
||||
vi.stubGlobal('navigator', {
|
||||
mediaDevices: {
|
||||
getUserMedia: vi.fn(async (constraints: MediaStreamConstraints) => constraints.video
|
||||
? createFakeCameraStream()
|
||||
: createFakeStream('track-default', ''))
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
// The camera is only shared from inside a voice session, so a dead socket wiping the
|
||||
// roster is the same false departure it is for voice. Cutting the video there left the
|
||||
// peer watching a frozen tile for the rest of the call.
|
||||
it('keeps sending camera video to a peer the roster forgot mid-share', async () => {
|
||||
const { manager, peer } = await startCameraShareInChannel();
|
||||
|
||||
manager.setAllowedVoicePeerIds([]);
|
||||
|
||||
expect(peer.peerData.videoSender?.track).not.toBeNull();
|
||||
|
||||
manager.destroy();
|
||||
});
|
||||
|
||||
// The peer's own word beats the roster, and it has to reach the camera in the same pass:
|
||||
// refreshing only the microphone left our video going to someone who just left voice.
|
||||
it('stops sending camera video as soon as the peer reports leaving voice', async () => {
|
||||
const { manager, peer } = await startCameraShareInChannel();
|
||||
|
||||
manager.notePeerVoiceReport('peer-1', { isConnected: false });
|
||||
|
||||
expect(peer.peerData.videoSender?.track).toBeNull();
|
||||
|
||||
manager.destroy();
|
||||
});
|
||||
|
||||
it('never opens a camera path for a peer nothing confirmed', async () => {
|
||||
const { manager, peer } = createManager();
|
||||
|
||||
await manager.toggleNoiseReduction(false);
|
||||
await manager.enableVoice();
|
||||
manager.startVoiceHeartbeat('voice-1', 'server-1');
|
||||
await manager.enableCamera();
|
||||
|
||||
expect(peer.peerData.videoSender?.track ?? null).toBeNull();
|
||||
|
||||
manager.destroy();
|
||||
});
|
||||
});
|
||||
|
||||
async function startCameraShareInChannel(): Promise<{ manager: MediaManager; peer: FakePeer }> {
|
||||
const { manager, peer } = createManager();
|
||||
|
||||
await manager.toggleNoiseReduction(false);
|
||||
await manager.enableVoice();
|
||||
manager.startVoiceHeartbeat('voice-1', 'server-1');
|
||||
manager.setAllowedVoicePeerIds(['peer-1']);
|
||||
await manager.enableCamera();
|
||||
|
||||
expect(peer.peerData.videoSender?.track).not.toBeNull();
|
||||
|
||||
return { manager, peer };
|
||||
}
|
||||
|
||||
async function startVoiceInChannel(): Promise<{ manager: MediaManager; peer: FakePeer }> {
|
||||
const { manager, peer } = createManager();
|
||||
|
||||
await manager.toggleNoiseReduction(false);
|
||||
await manager.enableVoice();
|
||||
manager.startVoiceHeartbeat('voice-1', 'server-1');
|
||||
manager.setAllowedVoicePeerIds(['peer-1']);
|
||||
|
||||
expect(peer.sender.track).not.toBeNull();
|
||||
|
||||
return { manager, peer };
|
||||
}
|
||||
|
||||
function createFakeStream(trackId: string, deviceId: string): MediaStream {
|
||||
const track: FakeTrack = {
|
||||
kind: 'audio',
|
||||
enabled: true,
|
||||
readyState: 'live',
|
||||
id: trackId,
|
||||
stop: vi.fn(),
|
||||
getSettings: () => ({ deviceId })
|
||||
};
|
||||
|
||||
return {
|
||||
id: `stream-${trackId}`,
|
||||
getAudioTracks: () => [track],
|
||||
getVideoTracks: () => [],
|
||||
getTracks: () => [track]
|
||||
} as unknown as MediaStream;
|
||||
}
|
||||
|
||||
function createFakeCameraStream(): MediaStream {
|
||||
const track: FakeTrack = {
|
||||
kind: 'video',
|
||||
enabled: true,
|
||||
readyState: 'live',
|
||||
id: 'camera-track',
|
||||
stop: vi.fn(),
|
||||
getSettings: () => ({ deviceId: 'camera' })
|
||||
};
|
||||
|
||||
return {
|
||||
id: 'stream-camera',
|
||||
getAudioTracks: () => [],
|
||||
getVideoTracks: () => [track],
|
||||
getTracks: () => [track]
|
||||
} as unknown as MediaStream;
|
||||
}
|
||||
|
||||
function readRequestedDeviceId(constraints: MediaStreamConstraints | undefined): string {
|
||||
const audio = constraints?.audio;
|
||||
|
||||
if (!audio || typeof audio === 'boolean') {
|
||||
return '';
|
||||
}
|
||||
|
||||
const { deviceId } = audio;
|
||||
|
||||
if (typeof deviceId === 'string') {
|
||||
return deviceId;
|
||||
}
|
||||
|
||||
const exact = (deviceId as { exact?: string } | undefined)?.exact;
|
||||
|
||||
return typeof exact === 'string' ? exact : '';
|
||||
}
|
||||
|
||||
function createFakePeer(withExistingAudioTransceiver: boolean): FakePeer {
|
||||
const sender: FakeSender = {
|
||||
track: null,
|
||||
replaceTrack: vi.fn(async (track: FakeTrack | null) => {
|
||||
sender.track = track;
|
||||
}),
|
||||
setStreams: vi.fn()
|
||||
};
|
||||
const transceivers: FakeTransceiver[] = withExistingAudioTransceiver
|
||||
? [{ sender, receiver: { track: { kind: 'audio' } }, direction: 'sendrecv' }]
|
||||
: [];
|
||||
const addTransceiver = vi.fn((kind: string) => {
|
||||
const created: FakeTransceiver = {
|
||||
sender: {
|
||||
track: null,
|
||||
replaceTrack: vi.fn(async (track: FakeTrack | null) => {
|
||||
created.sender.track = track;
|
||||
}),
|
||||
setStreams: vi.fn()
|
||||
},
|
||||
receiver: { track: { kind } },
|
||||
direction: 'sendrecv'
|
||||
};
|
||||
|
||||
transceivers.push(created);
|
||||
return created;
|
||||
});
|
||||
const removeTrack = vi.fn((sender: FakeSender) => {
|
||||
sender.track = null;
|
||||
});
|
||||
const connection = {
|
||||
signalingState: 'stable',
|
||||
getTransceivers: () => transceivers,
|
||||
getSenders: () => transceivers.map((transceiver) => transceiver.sender),
|
||||
addTransceiver,
|
||||
removeTrack
|
||||
};
|
||||
|
||||
return {
|
||||
peerData: { connection } as unknown as PeerData,
|
||||
sender,
|
||||
addTransceiver,
|
||||
removeTrack
|
||||
};
|
||||
}
|
||||
|
||||
function createManager(options: { withExistingAudioTransceiver?: boolean } = {}): {
|
||||
manager: MediaManager;
|
||||
peer: FakePeer;
|
||||
callbacks: { renegotiate: ReturnType<typeof vi.fn> } & MediaManagerCallbacks;
|
||||
} {
|
||||
const peer = createFakePeer(options.withExistingAudioTransceiver ?? true);
|
||||
const callbacks = {
|
||||
getActivePeers: () => new Map<string, PeerData>([['peer-1', peer.peerData]]),
|
||||
renegotiate: vi.fn(async () => undefined),
|
||||
broadcastMessage: vi.fn(),
|
||||
broadcastIdentityScopedMessage: vi.fn(),
|
||||
setCameraEnabled: vi.fn()
|
||||
};
|
||||
const logger = {
|
||||
info: vi.fn(),
|
||||
warn: vi.fn(),
|
||||
error: vi.fn(),
|
||||
logStream: vi.fn(),
|
||||
attachTrackDiagnostics: vi.fn()
|
||||
};
|
||||
const manager = new MediaManager(logger as never, callbacks);
|
||||
|
||||
return { manager, peer, callbacks };
|
||||
}
|
||||
|
||||
async function startVoice(options: {
|
||||
preferredInputDeviceId?: string;
|
||||
withExistingAudioTransceiver?: boolean;
|
||||
} = {}): Promise<{
|
||||
manager: MediaManager;
|
||||
peer: FakePeer;
|
||||
callbacks: { renegotiate: ReturnType<typeof vi.fn> } & MediaManagerCallbacks;
|
||||
firstStreamTracks: FakeTrack[];
|
||||
}> {
|
||||
const { manager, peer, callbacks } = createManager({
|
||||
withExistingAudioTransceiver: options.withExistingAudioTransceiver
|
||||
});
|
||||
|
||||
await manager.toggleNoiseReduction(false);
|
||||
|
||||
if (options.preferredInputDeviceId) {
|
||||
manager.setPreferredInputDeviceId(options.preferredInputDeviceId);
|
||||
}
|
||||
|
||||
const stream = await manager.enableVoice();
|
||||
|
||||
manager.setAllowedVoicePeerIds(['peer-1']);
|
||||
|
||||
return {
|
||||
manager,
|
||||
peer,
|
||||
callbacks,
|
||||
firstStreamTracks: stream.getAudioTracks() as unknown as FakeTrack[]
|
||||
};
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
/* eslint-disable @typescript-eslint/member-ordering, @typescript-eslint/no-unused-vars, */
|
||||
/* eslint-disable @typescript-eslint/no-unused-vars, */
|
||||
/**
|
||||
* Manages local voice and camera media: getUserMedia, mute, deafen,
|
||||
* attaching/detaching tracks to peer connections, bitrate tuning,
|
||||
@@ -11,8 +11,19 @@ import { ChatEvent } from '../../../shared-kernel';
|
||||
import { LatencyProfile } from '../realtime.constants';
|
||||
import { PeerData } from '../realtime.types';
|
||||
import { WebRTCLogger } from '../logging/webrtc-logger';
|
||||
import { PeerScopedIdentity } from '../peer-connection-manager/messaging/data-channel';
|
||||
import { NoiseReductionManager } from './noise-reduction.manager';
|
||||
import { loadVoiceSettingsFromStorage } from '../../../domains/voice-session/infrastructure/util/voice-settings-storage.util';
|
||||
import {
|
||||
SYSTEM_DEFAULT_AUDIO_DEVICE_ID,
|
||||
buildMicrophoneConstraints,
|
||||
isDeviceUnavailableError
|
||||
} from '../../../domains/voice-session/domain/logic/audio-device-selection.rules';
|
||||
import {
|
||||
decideVoicePathRouting,
|
||||
type PeerVoiceReport,
|
||||
type VoicePathRouting
|
||||
} from '../../../domains/voice-session/domain/logic/voice-path-routing.rules';
|
||||
import {
|
||||
TRACK_KIND_AUDIO,
|
||||
TRACK_KIND_VIDEO,
|
||||
@@ -41,9 +52,11 @@ export interface MediaManagerCallbacks {
|
||||
renegotiate(peerId: string): Promise<void>;
|
||||
/** Broadcast a message to all peers. */
|
||||
broadcastMessage(event: ChatEvent): void;
|
||||
/** Get identify credentials (for broadcasting). */
|
||||
getIdentifyOderId(): string;
|
||||
getIdentifyDisplayName(): string;
|
||||
/**
|
||||
* Broadcast a self-identifying message, rebuilt per peer so each peer receives the
|
||||
* actor id it knows us by on its own signal server.
|
||||
*/
|
||||
broadcastIdentityScopedMessage(buildEvent: (identity: PeerScopedIdentity) => ChatEvent): void;
|
||||
/** Push the current local camera state back into service-level signals. */
|
||||
setCameraEnabled?(enabled: boolean): void;
|
||||
}
|
||||
@@ -91,6 +104,13 @@ export class MediaManager {
|
||||
*/
|
||||
private _noiseReductionDesired = true;
|
||||
|
||||
/**
|
||||
* The microphone the user picked, or {@link SYSTEM_DEFAULT_AUDIO_DEVICE_ID}.
|
||||
* Every capture path reads it, so joining voice from the channel list honours
|
||||
* the same device as joining from the voice controls.
|
||||
*/
|
||||
private preferredInputDeviceId = SYSTEM_DEFAULT_AUDIO_DEVICE_ID;
|
||||
|
||||
// State tracked locally (the service exposes these via signals)
|
||||
private isVoiceActive = false;
|
||||
private isMicMuted = false;
|
||||
@@ -103,17 +123,26 @@ export class MediaManager {
|
||||
private currentVoiceServerId: string | undefined;
|
||||
private allowedVoicePeerIds = new Set<string>();
|
||||
|
||||
/** When the roster started listing each allowed peer, so a peer's own word can be dated against it. */
|
||||
private rosterPresenceSince = new Map<string, number>();
|
||||
|
||||
/** The last voice membership each peer reported over its data channel. */
|
||||
private peerVoiceReports = new Map<string, PeerVoiceReport>();
|
||||
|
||||
constructor(
|
||||
private readonly logger: WebRTCLogger,
|
||||
private callbacks: MediaManagerCallbacks
|
||||
) {
|
||||
this.noiseReduction = new NoiseReductionManager(logger);
|
||||
|
||||
// Read the persisted noise-reduction preference so enableVoice()
|
||||
// uses the correct value even before voice-controls loads.
|
||||
// Read the persisted preferences so enableVoice() uses the correct
|
||||
// values even before voice-controls loads.
|
||||
try {
|
||||
this._noiseReductionDesired = loadVoiceSettingsFromStorage().noiseReduction;
|
||||
} catch { /* keep default */ }
|
||||
const settings = loadVoiceSettingsFromStorage();
|
||||
|
||||
this._noiseReductionDesired = settings.noiseReduction;
|
||||
this.preferredInputDeviceId = settings.inputDevice;
|
||||
} catch { /* keep defaults */ }
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -170,6 +199,19 @@ export class MediaManager {
|
||||
getIsNoiseReductionEnabled(): boolean {
|
||||
return this._noiseReductionDesired;
|
||||
}
|
||||
/** The microphone the next capture will request. */
|
||||
getPreferredInputDeviceId(): string {
|
||||
return this.preferredInputDeviceId;
|
||||
}
|
||||
|
||||
/**
|
||||
* Remember which microphone to capture from, without touching a live capture.
|
||||
*
|
||||
* @param deviceId - Device id, or {@link SYSTEM_DEFAULT_AUDIO_DEVICE_ID} to follow the system.
|
||||
*/
|
||||
setPreferredInputDeviceId(deviceId: string): void {
|
||||
this.preferredInputDeviceId = deviceId || SYSTEM_DEFAULT_AUDIO_DEVICE_ID;
|
||||
}
|
||||
|
||||
setAllowedVoicePeerIds(peerIds: Iterable<string>): void {
|
||||
const nextAllowed = new Set(peerIds);
|
||||
@@ -179,10 +221,31 @@ export class MediaManager {
|
||||
}
|
||||
|
||||
this.allowedVoicePeerIds = nextAllowed;
|
||||
this.trackRosterPresence(nextAllowed);
|
||||
this.syncVoiceRouting();
|
||||
this.syncCameraRouting();
|
||||
}
|
||||
|
||||
/**
|
||||
* Record what a peer said about its own voice membership.
|
||||
*
|
||||
* This is the only positive evidence that a peer left our channel: the roster
|
||||
* can lose a peer for reasons that have nothing to do with voice.
|
||||
*/
|
||||
notePeerVoiceReport(peerId: string, report: Omit<PeerVoiceReport, 'at'>): void {
|
||||
this.peerVoiceReports.set(peerId, { ...report, at: Date.now() });
|
||||
|
||||
// Camera video is gated on the same report, so it has to answer to it in the same
|
||||
// pass - a peer that just said it left voice must not keep our camera either.
|
||||
this.refreshVoiceRouting();
|
||||
}
|
||||
|
||||
/** Forget a peer's voice claims once the peer connection is gone for good. */
|
||||
forgetPeerVoiceReport(peerId: string): void {
|
||||
this.peerVoiceReports.delete(peerId);
|
||||
this.rosterPresenceSince.delete(peerId);
|
||||
}
|
||||
|
||||
refreshVoiceRouting(): void {
|
||||
this.syncVoiceRouting();
|
||||
this.syncCameraRouting();
|
||||
@@ -206,57 +269,118 @@ export class MediaManager {
|
||||
this.localMediaStream = null;
|
||||
}
|
||||
|
||||
const mediaConstraints: MediaStreamConstraints = {
|
||||
audio: {
|
||||
echoCancellation: true,
|
||||
noiseSuppression: !this._noiseReductionDesired,
|
||||
autoGainControl: true
|
||||
},
|
||||
video: false
|
||||
};
|
||||
const stream = await this.captureMicrophone();
|
||||
const localStream = await this.adoptMicrophoneStream(stream);
|
||||
|
||||
this.logger.info('getUserMedia constraints', mediaConstraints);
|
||||
|
||||
if (!navigator.mediaDevices?.getUserMedia) {
|
||||
throw new Error(
|
||||
'navigator.mediaDevices is not available. ' +
|
||||
'This requires a secure context (HTTPS or localhost). ' +
|
||||
'If accessing from an external device, use HTTPS.'
|
||||
);
|
||||
}
|
||||
|
||||
const voicePermissionsGranted = await ensureMobileVoiceCapturePermissions();
|
||||
|
||||
if (!voicePermissionsGranted) {
|
||||
throw new Error('Microphone permission was not granted.');
|
||||
}
|
||||
|
||||
const stream = await navigator.mediaDevices.getUserMedia(mediaConstraints);
|
||||
|
||||
this.rawMicStream = stream;
|
||||
|
||||
// If the user wants noise reduction, pipe through the denoiser
|
||||
this.localMediaStream = this._noiseReductionDesired
|
||||
? await this.noiseReduction.enable(stream)
|
||||
: stream;
|
||||
|
||||
// Apply input gain (mic volume) before sending to peers
|
||||
await this.applyInputGainToCurrentStream();
|
||||
|
||||
this.logger.logStream('localVoice', this.localMediaStream);
|
||||
|
||||
this.bindLocalTracksToAllPeers();
|
||||
|
||||
this.isVoiceActive = true;
|
||||
this.voiceConnected$.next();
|
||||
void startMobileVoiceForegroundSession();
|
||||
return this.localMediaStream;
|
||||
return localStream;
|
||||
} catch (error) {
|
||||
this.logger.error('Failed to getUserMedia', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Move the live microphone to another device without leaving voice.
|
||||
*
|
||||
* The new track is swapped into the existing peer senders, so remote peers
|
||||
* keep the same audio track and never see a leave/rejoin. The previous
|
||||
* device is released only after the swap, to avoid a gap of silence.
|
||||
*
|
||||
* @param deviceId - Device id, or {@link SYSTEM_DEFAULT_AUDIO_DEVICE_ID}.
|
||||
* @returns The new local stream, or `null` when voice is not active.
|
||||
*/
|
||||
async switchInputDevice(deviceId: string): Promise<MediaStream | null> {
|
||||
this.setPreferredInputDeviceId(deviceId);
|
||||
|
||||
if (!this.isVoiceActive) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const previousRawStream = this.rawMicStream;
|
||||
const stream = await this.captureMicrophone();
|
||||
const localStream = await this.adoptMicrophoneStream(stream);
|
||||
|
||||
if (previousRawStream && previousRawStream !== stream) {
|
||||
previousRawStream.getTracks().forEach((track) => track.stop());
|
||||
}
|
||||
|
||||
this.logger.info('Switched microphone', { deviceId: this.preferredInputDeviceId });
|
||||
return localStream;
|
||||
}
|
||||
|
||||
private async captureMicrophone(): Promise<MediaStream> {
|
||||
const mediaConstraints = buildMicrophoneConstraints({
|
||||
deviceId: this.preferredInputDeviceId,
|
||||
browserNoiseSuppression: !this._noiseReductionDesired
|
||||
});
|
||||
|
||||
this.logger.info('getUserMedia constraints', mediaConstraints);
|
||||
|
||||
if (!navigator.mediaDevices?.getUserMedia) {
|
||||
throw new Error(
|
||||
'navigator.mediaDevices is not available. ' +
|
||||
'This requires a secure context (HTTPS or localhost). ' +
|
||||
'If accessing from an external device, use HTTPS.'
|
||||
);
|
||||
}
|
||||
|
||||
const voicePermissionsGranted = await ensureMobileVoiceCapturePermissions();
|
||||
|
||||
if (!voicePermissionsGranted) {
|
||||
throw new Error('Microphone permission was not granted.');
|
||||
}
|
||||
|
||||
try {
|
||||
return await navigator.mediaDevices.getUserMedia(mediaConstraints);
|
||||
} catch (error) {
|
||||
if (!this.preferredInputDeviceId || !isDeviceUnavailableError(error)) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
// The saved device is gone or already claimed. Requesting it exactly is
|
||||
// what makes the picker work at all, so the retry - not a looser
|
||||
// constraint - is what keeps a stale id from ending the call.
|
||||
this.logger.warn('Saved microphone unavailable, falling back to the system default', {
|
||||
deviceId: this.preferredInputDeviceId
|
||||
});
|
||||
|
||||
this.preferredInputDeviceId = SYSTEM_DEFAULT_AUDIO_DEVICE_ID;
|
||||
|
||||
return await navigator.mediaDevices.getUserMedia(buildMicrophoneConstraints({
|
||||
deviceId: SYSTEM_DEFAULT_AUDIO_DEVICE_ID,
|
||||
browserNoiseSuppression: !this._noiseReductionDesired
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Route a freshly captured microphone stream through the denoiser and gain
|
||||
* pipeline, honour the current mute state, and bind it to every peer.
|
||||
*/
|
||||
private async adoptMicrophoneStream(stream: MediaStream): Promise<MediaStream> {
|
||||
this.rawMicStream = stream;
|
||||
|
||||
// If the user wants noise reduction, pipe through the denoiser
|
||||
this.localMediaStream = this._noiseReductionDesired
|
||||
? await this.noiseReduction.enable(stream)
|
||||
: stream;
|
||||
|
||||
// Apply input gain (mic volume) before sending to peers
|
||||
await this.applyInputGainToCurrentStream();
|
||||
this.applyCurrentMuteState();
|
||||
|
||||
const localStream = this.localMediaStream;
|
||||
|
||||
this.logger.logStream('localVoice', localStream);
|
||||
|
||||
this.bindLocalTracksToAllPeers();
|
||||
|
||||
this.isVoiceActive = true;
|
||||
void startMobileVoiceForegroundSession();
|
||||
return localStream;
|
||||
}
|
||||
|
||||
/**
|
||||
* Stop all local media tracks and remove audio senders from peers.
|
||||
* The peer connections themselves are kept alive.
|
||||
@@ -290,6 +414,7 @@ export class MediaManager {
|
||||
this.currentVoiceRoomId = undefined;
|
||||
this.currentVoiceServerId = undefined;
|
||||
this.allowedVoicePeerIds.clear();
|
||||
this.rosterPresenceSince.clear();
|
||||
void stopMobileVoiceForegroundSession();
|
||||
}
|
||||
|
||||
@@ -301,24 +426,17 @@ export class MediaManager {
|
||||
* denoiser before being sent to peers.
|
||||
*/
|
||||
async setLocalStream(stream: MediaStream): Promise<void> {
|
||||
this.rawMicStream = stream;
|
||||
const previousRawStream = this.rawMicStream;
|
||||
|
||||
this.logger.info('setLocalStream - noiseReductionDesired =', this._noiseReductionDesired);
|
||||
|
||||
// Pipe through the denoiser when the user wants noise reduction
|
||||
if (this._noiseReductionDesired) {
|
||||
this.logger.info('Piping new stream through noise reduction');
|
||||
this.localMediaStream = await this.noiseReduction.enable(stream);
|
||||
} else {
|
||||
this.localMediaStream = stream;
|
||||
await this.adoptMicrophoneStream(stream);
|
||||
|
||||
if (previousRawStream && previousRawStream !== stream) {
|
||||
previousRawStream.getTracks().forEach((track) => track.stop());
|
||||
}
|
||||
|
||||
// Apply input gain (mic volume) before sending to peers
|
||||
await this.applyInputGainToCurrentStream();
|
||||
|
||||
this.bindLocalTracksToAllPeers();
|
||||
this.isVoiceActive = true;
|
||||
this.voiceConnected$.next();
|
||||
void startMobileVoiceForegroundSession();
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -615,50 +733,189 @@ export class MediaManager {
|
||||
const localAudioTrack = localStream?.getAudioTracks()[0] || null;
|
||||
|
||||
peers.forEach((peerData, peerId) => {
|
||||
const didChange = localStream && localAudioTrack && this.allowedVoicePeerIds.has(peerId)
|
||||
const routing = this.decideMicRoutingForPeer(peerId, peerData, localAudioTrack);
|
||||
|
||||
if (routing === 'hold') {
|
||||
this.logger.info('Holding an established voice path without confirmed peer membership', { peerId });
|
||||
}
|
||||
|
||||
const needsRenegotiation = localStream && localAudioTrack && routing !== 'close'
|
||||
? this.attachVoiceTrackToPeer(peerId, peerData, localStream, localAudioTrack)
|
||||
: this.detachVoiceTrackFromPeer(peerData);
|
||||
|
||||
if (didChange) {
|
||||
if (needsRenegotiation) {
|
||||
void this.callbacks.renegotiate(peerId);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private decideMicRoutingForPeer(
|
||||
peerId: string,
|
||||
peerData: PeerData,
|
||||
localAudioTrack: MediaStreamTrack | null
|
||||
): VoicePathRouting {
|
||||
return decideVoicePathRouting({
|
||||
hasLocalVoice: this.isVoiceActive && !!localAudioTrack,
|
||||
localVoiceChannel: this.getLocalVoiceChannel(),
|
||||
rosterPresenceAt: this.getRosterPresenceAt(peerId),
|
||||
peerReport: this.peerVoiceReports.get(peerId) ?? null,
|
||||
hasEstablishedPath: this.hasEstablishedMicPath(peerData),
|
||||
isPeerConnectionClosed: peerData.connection.connectionState === 'closed'
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether a peer's incoming voice may still be heard.
|
||||
*
|
||||
* Playback answers to the same evidence as the microphone: a roster that lost the
|
||||
* peer is not a reason to mute a peer we are still negotiated with, while a peer
|
||||
* nothing confirmed is never opened on a guess.
|
||||
*/
|
||||
mayHearPeerVoice(peerId: string, hasEstablishedPlayback: boolean): boolean {
|
||||
return decideVoicePathRouting({
|
||||
hasLocalVoice: this.isVoiceActive,
|
||||
localVoiceChannel: this.getLocalVoiceChannel(),
|
||||
rosterPresenceAt: this.getRosterPresenceAt(peerId),
|
||||
peerReport: this.peerVoiceReports.get(peerId) ?? null,
|
||||
hasEstablishedPath: hasEstablishedPlayback,
|
||||
isPeerConnectionClosed: false
|
||||
}) !== 'close';
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether a peer connection being built may carry our microphone in its first offer.
|
||||
*
|
||||
* Attaching at creation time is what spares a confirmed channel member a second SDP
|
||||
* exchange, but the first offer opens a path like any other, so it answers to the same
|
||||
* evidence. Attaching on a guess would also be self-perpetuating: the track alone makes
|
||||
* the next routing pass read an established path and hold it.
|
||||
*/
|
||||
mayOpenVoicePathToPeer(peerId: string): boolean {
|
||||
return decideVoicePathRouting({
|
||||
hasLocalVoice: this.isVoiceActive,
|
||||
localVoiceChannel: this.getLocalVoiceChannel(),
|
||||
rosterPresenceAt: this.getRosterPresenceAt(peerId),
|
||||
peerReport: this.peerVoiceReports.get(peerId) ?? null,
|
||||
hasEstablishedPath: false,
|
||||
isPeerConnectionClosed: false
|
||||
}) === 'open';
|
||||
}
|
||||
|
||||
private getLocalVoiceChannel(): { roomId?: string; serverId?: string } {
|
||||
return {
|
||||
roomId: this.currentVoiceRoomId,
|
||||
serverId: this.currentVoiceServerId
|
||||
};
|
||||
}
|
||||
|
||||
private getRosterPresenceAt(peerId: string): number | null {
|
||||
return this.allowedVoicePeerIds.has(peerId)
|
||||
? this.rosterPresenceSince.get(peerId) ?? 0
|
||||
: null;
|
||||
}
|
||||
|
||||
/** Whether our microphone is already on a negotiated sender for this peer. */
|
||||
private hasEstablishedMicPath(peerData: PeerData): boolean {
|
||||
const audioSender = peerData.audioSender
|
||||
?? peerData.connection
|
||||
.getSenders()
|
||||
.find((sender) => sender !== peerData.screenAudioSender && sender.track?.kind === TRACK_KIND_AUDIO);
|
||||
|
||||
return !!audioSender?.track;
|
||||
}
|
||||
|
||||
/**
|
||||
* Camera video answers to the same evidence as the microphone.
|
||||
*
|
||||
* The camera is only ever shared from inside a voice session (see {@link enableCamera}),
|
||||
* so a roster that lost a peer mid-share is the same false departure it is for voice -
|
||||
* and cutting the video there left the peer looking at a frozen tile for the rest of
|
||||
* the call.
|
||||
*/
|
||||
private decideCameraRoutingForPeer(peerId: string, peerData: PeerData): VoicePathRouting {
|
||||
return decideVoicePathRouting({
|
||||
hasLocalVoice: this.isVoiceActive,
|
||||
localVoiceChannel: this.getLocalVoiceChannel(),
|
||||
rosterPresenceAt: this.getRosterPresenceAt(peerId),
|
||||
peerReport: this.peerVoiceReports.get(peerId) ?? null,
|
||||
hasEstablishedPath: this.hasEstablishedCameraPath(peerData),
|
||||
isPeerConnectionClosed: peerData.connection.connectionState === 'closed'
|
||||
});
|
||||
}
|
||||
|
||||
/** Whether our camera is already on a negotiated sender for this peer. */
|
||||
private hasEstablishedCameraPath(peerData: PeerData): boolean {
|
||||
const videoSender = peerData.videoSender
|
||||
?? peerData.connection
|
||||
.getSenders()
|
||||
.find((sender) => sender !== peerData.screenVideoSender && sender.track?.kind === TRACK_KIND_VIDEO);
|
||||
|
||||
return !!videoSender?.track;
|
||||
}
|
||||
|
||||
private trackRosterPresence(nextAllowed: Set<string>): void {
|
||||
const now = Date.now();
|
||||
|
||||
for (const peerId of nextAllowed) {
|
||||
if (!this.rosterPresenceSince.has(peerId)) {
|
||||
this.rosterPresenceSince.set(peerId, now);
|
||||
}
|
||||
}
|
||||
|
||||
for (const peerId of this.rosterPresenceSince.keys()) {
|
||||
if (!nextAllowed.has(peerId)) {
|
||||
this.rosterPresenceSince.delete(peerId);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private syncCameraRouting(): void {
|
||||
const peers = this.callbacks.getActivePeers();
|
||||
const localCameraStream = this.localCameraStream;
|
||||
const localCameraTrack = localCameraStream?.getVideoTracks()[0] || null;
|
||||
|
||||
peers.forEach((peerData, peerId) => {
|
||||
const didChange = localCameraStream && localCameraTrack && this.allowedVoicePeerIds.has(peerId)
|
||||
const routing = this.decideCameraRoutingForPeer(peerId, peerData);
|
||||
const needsRenegotiation = localCameraStream && localCameraTrack && routing !== 'close'
|
||||
? this.attachCameraTrackToPeer(peerId, peerData, localCameraStream, localCameraTrack)
|
||||
: this.detachCameraTrackFromPeer(peerData, peerId);
|
||||
|
||||
if (didChange) {
|
||||
if (needsRenegotiation) {
|
||||
void this.callbacks.renegotiate(peerId);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Bind the local audio track to one peer.
|
||||
*
|
||||
* @returns Whether the change needs SDP renegotiation. A plain track swap on
|
||||
* an already negotiated sender does not: that is what keeps a live
|
||||
* microphone change from disturbing the session.
|
||||
*/
|
||||
private attachVoiceTrackToPeer(
|
||||
peerId: string,
|
||||
peerData: PeerData,
|
||||
localStream: MediaStream,
|
||||
localAudioTrack: MediaStreamTrack
|
||||
): boolean {
|
||||
const audioTransceiver = this.getOrCreateReusableTransceiver(peerData, TRACK_KIND_AUDIO, {
|
||||
preferredSender: peerData.audioSender,
|
||||
excludedSenders: [peerData.screenAudioSender]
|
||||
});
|
||||
const { transceiver: audioTransceiver, created } = this.getOrCreateReusableTransceiver(
|
||||
peerData,
|
||||
TRACK_KIND_AUDIO,
|
||||
{
|
||||
preferredSender: peerData.audioSender,
|
||||
excludedSenders: [peerData.screenAudioSender]
|
||||
}
|
||||
);
|
||||
const audioSender = audioTransceiver.sender;
|
||||
const needsDirectionRestore = audioTransceiver.direction === TRANSCEIVER_RECV_ONLY
|
||||
|| audioTransceiver.direction === TRANSCEIVER_INACTIVE;
|
||||
const needsTrackReplace = audioSender.track !== localAudioTrack;
|
||||
const needsRenegotiation = created || needsDirectionRestore;
|
||||
|
||||
peerData.audioSender = audioSender;
|
||||
|
||||
if (!needsDirectionRestore && !needsTrackReplace) {
|
||||
if (!needsRenegotiation && !needsTrackReplace) {
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -666,7 +923,9 @@ export class MediaManager {
|
||||
audioTransceiver.direction = TRANSCEIVER_SEND_RECV;
|
||||
}
|
||||
|
||||
if (typeof audioSender.setStreams === 'function') {
|
||||
// setStreams rewrites the msid, which only reaches the peer through SDP.
|
||||
// Skip it on a plain swap so the swap stays renegotiation-free.
|
||||
if (needsRenegotiation && typeof audioSender.setStreams === 'function') {
|
||||
audioSender.setStreams(localStream);
|
||||
}
|
||||
|
||||
@@ -677,7 +936,7 @@ export class MediaManager {
|
||||
.catch((error) => this.logger.error('audio replaceTrack failed', error));
|
||||
}
|
||||
|
||||
return true;
|
||||
return needsRenegotiation;
|
||||
}
|
||||
|
||||
private detachVoiceTrackFromPeer(peerData: PeerData): boolean {
|
||||
@@ -692,24 +951,30 @@ export class MediaManager {
|
||||
return true;
|
||||
}
|
||||
|
||||
/** @returns Whether the change needs SDP renegotiation (see {@link attachVoiceTrackToPeer}). */
|
||||
private attachCameraTrackToPeer(
|
||||
peerId: string,
|
||||
peerData: PeerData,
|
||||
localStream: MediaStream,
|
||||
localCameraTrack: MediaStreamTrack
|
||||
): boolean {
|
||||
const videoTransceiver = this.getOrCreateReusableTransceiver(peerData, TRACK_KIND_VIDEO, {
|
||||
preferredSender: peerData.videoSender,
|
||||
excludedSenders: [peerData.screenVideoSender]
|
||||
});
|
||||
const { transceiver: videoTransceiver, created } = this.getOrCreateReusableTransceiver(
|
||||
peerData,
|
||||
TRACK_KIND_VIDEO,
|
||||
{
|
||||
preferredSender: peerData.videoSender,
|
||||
excludedSenders: [peerData.screenVideoSender]
|
||||
}
|
||||
);
|
||||
const videoSender = videoTransceiver.sender;
|
||||
const needsDirectionRestore = videoTransceiver.direction === TRANSCEIVER_RECV_ONLY
|
||||
|| videoTransceiver.direction === TRANSCEIVER_INACTIVE;
|
||||
const needsTrackReplace = videoSender.track !== localCameraTrack;
|
||||
const needsRenegotiation = created || needsDirectionRestore;
|
||||
|
||||
peerData.videoSender = videoSender;
|
||||
|
||||
if (!needsDirectionRestore && !needsTrackReplace) {
|
||||
if (!needsRenegotiation && !needsTrackReplace) {
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -717,7 +982,7 @@ export class MediaManager {
|
||||
videoTransceiver.direction = TRANSCEIVER_SEND_RECV;
|
||||
}
|
||||
|
||||
if (typeof videoSender.setStreams === 'function') {
|
||||
if (needsRenegotiation && typeof videoSender.setStreams === 'function') {
|
||||
videoSender.setStreams(localStream);
|
||||
}
|
||||
|
||||
@@ -728,7 +993,7 @@ export class MediaManager {
|
||||
.catch((error) => this.logger.error('camera replaceTrack failed', error));
|
||||
}
|
||||
|
||||
return true;
|
||||
return needsRenegotiation;
|
||||
}
|
||||
|
||||
private detachCameraTrackFromPeer(peerData: PeerData, peerId: string): boolean {
|
||||
@@ -778,6 +1043,12 @@ export class MediaManager {
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Find a transceiver to send `kind` on, creating one only as a last resort.
|
||||
*
|
||||
* `created` tells the caller whether the peer needs a new SDP exchange: a
|
||||
* transceiver the remote has never seen cannot carry media until it does.
|
||||
*/
|
||||
private getOrCreateReusableTransceiver(
|
||||
peerData: PeerData,
|
||||
kind: typeof TRACK_KIND_AUDIO | typeof TRACK_KIND_VIDEO,
|
||||
@@ -785,7 +1056,7 @@ export class MediaManager {
|
||||
preferredSender?: RTCRtpSender;
|
||||
excludedSenders?: (RTCRtpSender | undefined)[];
|
||||
}
|
||||
): RTCRtpTransceiver {
|
||||
): { transceiver: RTCRtpTransceiver; created: boolean } {
|
||||
const excludedSenders = new Set(
|
||||
(options.excludedSenders ?? []).filter((sender): sender is RTCRtpSender => !!sender)
|
||||
);
|
||||
@@ -795,7 +1066,7 @@ export class MediaManager {
|
||||
: null;
|
||||
|
||||
if (preferredTransceiver) {
|
||||
return preferredTransceiver;
|
||||
return { transceiver: preferredTransceiver, created: false };
|
||||
}
|
||||
|
||||
const attachedSenderTransceiver = existingTransceivers.find((transceiver) =>
|
||||
@@ -804,7 +1075,7 @@ export class MediaManager {
|
||||
);
|
||||
|
||||
if (attachedSenderTransceiver) {
|
||||
return attachedSenderTransceiver;
|
||||
return { transceiver: attachedSenderTransceiver, created: false };
|
||||
}
|
||||
|
||||
const reusableReceiverTransceiver = existingTransceivers.find((transceiver) =>
|
||||
@@ -814,20 +1085,20 @@ export class MediaManager {
|
||||
);
|
||||
|
||||
if (reusableReceiverTransceiver) {
|
||||
return reusableReceiverTransceiver;
|
||||
return { transceiver: reusableReceiverTransceiver, created: false };
|
||||
}
|
||||
|
||||
return peerData.connection.addTransceiver(kind, {
|
||||
direction: TRANSCEIVER_SEND_RECV
|
||||
});
|
||||
return {
|
||||
transceiver: peerData.connection.addTransceiver(kind, {
|
||||
direction: TRANSCEIVER_SEND_RECV
|
||||
}),
|
||||
created: true
|
||||
};
|
||||
}
|
||||
|
||||
/** Broadcast a voice-presence state event to all connected peers. */
|
||||
private broadcastVoicePresence(): void {
|
||||
const oderId = this.callbacks.getIdentifyOderId();
|
||||
const displayName = this.callbacks.getIdentifyDisplayName();
|
||||
|
||||
this.callbacks.broadcastMessage({
|
||||
this.callbacks.broadcastIdentityScopedMessage(({ displayName, oderId }) => ({
|
||||
type: P2P_TYPE_VOICE_STATE,
|
||||
oderId,
|
||||
displayName,
|
||||
@@ -838,20 +1109,17 @@ export class MediaManager {
|
||||
roomId: this.currentVoiceRoomId,
|
||||
serverId: this.currentVoiceServerId
|
||||
}
|
||||
});
|
||||
}));
|
||||
}
|
||||
|
||||
/** Broadcast the local camera state to all connected peers. */
|
||||
private broadcastCameraState(): void {
|
||||
const oderId = this.callbacks.getIdentifyOderId();
|
||||
const displayName = this.callbacks.getIdentifyDisplayName();
|
||||
|
||||
this.callbacks.broadcastMessage({
|
||||
this.callbacks.broadcastIdentityScopedMessage(({ displayName, oderId }) => ({
|
||||
type: P2P_TYPE_CAMERA_STATE,
|
||||
oderId,
|
||||
displayName,
|
||||
isCameraEnabled: this.isCameraActive
|
||||
});
|
||||
}));
|
||||
}
|
||||
|
||||
// -- Input gain helpers --
|
||||
|
||||
@@ -59,6 +59,13 @@ export class VoiceSessionController {
|
||||
this.syncMediaSignals();
|
||||
}
|
||||
|
||||
async switchInputDevice(deviceId: string): Promise<MediaStream | null> {
|
||||
const stream = await this.dependencies.mediaManager.switchInputDevice(deviceId);
|
||||
|
||||
this.syncMediaSignals();
|
||||
return stream;
|
||||
}
|
||||
|
||||
toggleMute(muted?: boolean): void {
|
||||
this.dependencies.mediaManager.toggleMute(muted);
|
||||
this.dependencies.setMuted(this.dependencies.mediaManager.getIsMicMuted());
|
||||
|
||||
+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[]>(),
|
||||
|
||||
@@ -11,7 +11,7 @@
|
||||
* This file wires them together and exposes a public API that is
|
||||
* identical to the old monolithic service so consumers don't change.
|
||||
*/
|
||||
/* eslint-disable @typescript-eslint/member-ordering, @typescript-eslint/no-non-null-assertion */
|
||||
/* eslint-disable @typescript-eslint/no-non-null-assertion */
|
||||
import {
|
||||
Injectable,
|
||||
inject,
|
||||
@@ -37,8 +37,12 @@ import { MediaManager } from './media/media.manager';
|
||||
import { ScreenShareManager } from './media/screen-share.manager';
|
||||
import { VoiceSessionController } from './media/voice-session-controller';
|
||||
import { IceServerSettingsService } from './ice-server-settings.service';
|
||||
import type { PeerData, VoiceStateSnapshot } from './realtime.types';
|
||||
import { LatencyProfile } from './realtime.constants';
|
||||
import type {
|
||||
PeerData,
|
||||
PeerRecoveryStatusEvent,
|
||||
VoiceStateSnapshot
|
||||
} from './realtime.types';
|
||||
import { LatencyProfile, P2P_TYPE_VOICE_STATE } from './realtime.constants';
|
||||
import { ScreenShareStartOptions } from './screen-share.config';
|
||||
import { WebRTCLogger } from './logging/webrtc-logger';
|
||||
import { PeerConnectionManager } from './peer-connection-manager/peer-connection.manager';
|
||||
@@ -114,6 +118,10 @@ export class WebRTCService implements OnDestroy {
|
||||
get onVoiceConnected(): Observable<void> {
|
||||
return this.peerMediaFacade.onVoiceConnected;
|
||||
}
|
||||
/** Peer recovery gave up on a peer, or re-armed it after signaling returned. */
|
||||
get onPeerRecoveryStatus(): Observable<PeerRecoveryStatusEvent> {
|
||||
return this.peerManager.peerRecoveryStatus$.asObservable();
|
||||
}
|
||||
|
||||
private readonly peerManager: PeerConnectionManager;
|
||||
private readonly mediaManager: MediaManager;
|
||||
@@ -199,9 +207,13 @@ export class WebRTCService implements OnDestroy {
|
||||
this.peerManager.setCallbacks({
|
||||
sendRawMessage: (msg: Record<string, unknown>) => this.signalingTransportHandler.sendRawMessage(msg),
|
||||
getLocalMediaStream: (): MediaStream | null => this.peerMediaFacade.getLocalStream(),
|
||||
mayOpenVoicePathToPeer: (peerId: string): boolean =>
|
||||
this.peerMediaFacade.mayOpenVoicePathToPeer(peerId),
|
||||
isSignalingConnected: (): boolean => this.state.isSignalingConnected(),
|
||||
getVoiceStateSnapshot: (): VoiceStateSnapshot => this.voiceSessionController.getCurrentVoiceState(),
|
||||
getIdentifyCredentials: () => this.signalingTransportHandler.getIdentifyCredentials(),
|
||||
getIdentifyCredentialsForPeer: (peerId: string) =>
|
||||
this.signalingTransportHandler.getIdentifyCredentialsForPeer(peerId),
|
||||
getLocalPeerId: (): string => this.state.getLocalPeerId(),
|
||||
isScreenSharingActive: (): boolean => this.state.isScreenSharingActive(),
|
||||
isCameraEnabled: (): boolean => this.state.isCameraEnabledActive(),
|
||||
@@ -212,8 +224,8 @@ export class WebRTCService implements OnDestroy {
|
||||
getActivePeers: (): Map<string, PeerData> => this.peerMediaFacade.getActivePeers(),
|
||||
renegotiate: (peerId: string): Promise<void> => this.peerMediaFacade.renegotiate(peerId),
|
||||
broadcastMessage: (event: ChatEvent): void => this.peerMediaFacade.broadcastMessage(event),
|
||||
getIdentifyOderId: (): string => this.signalingTransportHandler.getIdentifyOderId(),
|
||||
getIdentifyDisplayName: (): string => this.signalingTransportHandler.getIdentifyDisplayName(),
|
||||
broadcastIdentityScopedMessage: (buildEvent): void =>
|
||||
this.peerMediaFacade.broadcastIdentityScopedMessage(buildEvent),
|
||||
setCameraEnabled: (enabled: boolean): void => this.state.setCameraEnabled(enabled)
|
||||
});
|
||||
|
||||
@@ -230,7 +242,8 @@ export class WebRTCService implements OnDestroy {
|
||||
});
|
||||
|
||||
this.signalingMessageHandler = new IncomingSignalingMessageHandler({
|
||||
getLocalOderId: () => this.signalingTransportHandler.getIdentifyCredentials()?.oderId ?? null,
|
||||
getLocalOderIdForSignalUrl: (signalUrl: string) =>
|
||||
this.signalingTransportHandler.getLocalOderIdForSignalUrl(signalUrl),
|
||||
getEffectiveServerId: () => this.voiceSessionController.getEffectiveServerId(this.state.currentServerId),
|
||||
isVoiceConnected: () => this.state.isVoiceConnectedActive(),
|
||||
peerManager: this.peerManager,
|
||||
@@ -262,9 +275,10 @@ export class WebRTCService implements OnDestroy {
|
||||
|
||||
private wireManagerEvents(): void {
|
||||
// Internal control-plane messages for on-demand screen-share delivery.
|
||||
this.peerManager.messageReceived$.subscribe((event) =>
|
||||
this.remoteScreenShareRequestController.handlePeerControlMessage(event)
|
||||
);
|
||||
this.peerManager.messageReceived$.subscribe((event) => {
|
||||
this.remoteScreenShareRequestController.handlePeerControlMessage(event);
|
||||
this.notePeerVoiceReport(event);
|
||||
});
|
||||
|
||||
// Peer manager -> connected peers signal
|
||||
this.peerManager.connectedPeersChanged$.subscribe((peers: string[]) =>
|
||||
@@ -285,6 +299,7 @@ export class WebRTCService implements OnDestroy {
|
||||
|
||||
this.peerManager.peerDisconnected$.subscribe((peerId) => {
|
||||
this.remoteScreenShareRequestController.handlePeerDisconnected(peerId);
|
||||
this.mediaManager.forgetPeerVoiceReport(peerId);
|
||||
});
|
||||
|
||||
// Media manager -> voice connected signal
|
||||
@@ -298,6 +313,31 @@ export class WebRTCService implements OnDestroy {
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* A peer's own `voice-state` message is the only evidence that can prove it left
|
||||
* our voice channel. The roster can lose a peer for reasons that have nothing to
|
||||
* do with voice - a dead socket makes the signal server broadcast `user_left` -
|
||||
* and that must never cut a negotiated media path.
|
||||
*/
|
||||
private notePeerVoiceReport(event: ChatEvent): void {
|
||||
if (event.type !== P2P_TYPE_VOICE_STATE) {
|
||||
return;
|
||||
}
|
||||
|
||||
const peerId = event.fromPeerId;
|
||||
const voiceState = event.voiceState;
|
||||
|
||||
if (!peerId || !voiceState) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.mediaManager.notePeerVoiceReport(peerId, {
|
||||
isConnected: voiceState.isConnected ?? false,
|
||||
roomId: voiceState.roomId,
|
||||
serverId: voiceState.serverId
|
||||
});
|
||||
}
|
||||
|
||||
private handleSignalingConnectionStatus(signalUrl: string, connected: boolean, errorMessage?: string): void {
|
||||
this.state.updateSignalingConnectionStatus(
|
||||
connected ? true : this.signalingCoordinator.isAnySignalingConnected(),
|
||||
@@ -306,6 +346,8 @@ export class WebRTCService implements OnDestroy {
|
||||
);
|
||||
|
||||
if (connected) {
|
||||
// Peers whose reconnect budget ran out during the outage stay dead until re-armed.
|
||||
this.peerManager.resumeStalledPeerRecovery();
|
||||
this.signalingReconnectedSubject$.next(signalUrl);
|
||||
}
|
||||
}
|
||||
@@ -503,9 +545,10 @@ export class WebRTCService implements OnDestroy {
|
||||
*
|
||||
* @param peerId - The target peer ID.
|
||||
* @param event - The chat event to send.
|
||||
* @returns whether the event reached the peer's open data channel.
|
||||
*/
|
||||
sendToPeer(peerId: string, event: ChatEvent): void {
|
||||
this.peerMediaFacade.sendToPeer(peerId, event);
|
||||
sendToPeer(peerId: string, event: ChatEvent): boolean {
|
||||
return this.peerMediaFacade.sendToPeer(peerId, event);
|
||||
}
|
||||
|
||||
syncRemoteScreenShareRequests(peerIds: string[], enabled: boolean): void {
|
||||
@@ -653,6 +696,16 @@ export class WebRTCService implements OnDestroy {
|
||||
await this.voiceSessionController.setLocalStream(stream);
|
||||
}
|
||||
|
||||
/**
|
||||
* Move the live microphone to another device without leaving voice.
|
||||
*
|
||||
* @param deviceId - Device id, or an empty string to follow the system default.
|
||||
* @returns The new local stream, or `null` when voice is not active.
|
||||
*/
|
||||
async switchInputDevice(deviceId: string): Promise<MediaStream | null> {
|
||||
return await this.voiceSessionController.switchInputDevice(deviceId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Toggle the local microphone mute state.
|
||||
*
|
||||
@@ -746,6 +799,14 @@ export class WebRTCService implements OnDestroy {
|
||||
this.mediaManager.setAllowedVoicePeerIds(allowedPeerIds);
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether a peer's incoming voice may be heard, judged on the same evidence as the
|
||||
* outgoing microphone rather than on the roster alone.
|
||||
*/
|
||||
mayHearPeerVoice(peerId: string, hasEstablishedPlayback: boolean): boolean {
|
||||
return this.mediaManager.mayHearPeerVoice(peerId, hasEstablishedPlayback);
|
||||
}
|
||||
|
||||
/**
|
||||
* Start sharing the screen (or a window) with all connected peers.
|
||||
*
|
||||
|
||||
@@ -3,6 +3,7 @@ import { ChatEvent } from '../../../shared-kernel';
|
||||
import { ScreenShareStartOptions } from '../screen-share.config';
|
||||
import { PeerData } from '../realtime.types';
|
||||
import { MediaManager } from '../media/media.manager';
|
||||
import { PeerScopedIdentity } from '../peer-connection-manager/messaging/data-channel';
|
||||
import { PeerConnectionManager } from '../peer-connection-manager/peer-connection.manager';
|
||||
import { ScreenShareManager } from '../media/screen-share.manager';
|
||||
|
||||
@@ -49,8 +50,12 @@ export class PeerMediaFacade {
|
||||
this.dependencies.peerManager.broadcastMessage(event);
|
||||
}
|
||||
|
||||
sendToPeer(peerId: string, event: ChatEvent): void {
|
||||
this.dependencies.peerManager.sendToPeer(peerId, event);
|
||||
broadcastIdentityScopedMessage(buildEvent: (identity: PeerScopedIdentity) => ChatEvent): void {
|
||||
this.dependencies.peerManager.broadcastIdentityScopedMessage(buildEvent);
|
||||
}
|
||||
|
||||
sendToPeer(peerId: string, event: ChatEvent): boolean {
|
||||
return this.dependencies.peerManager.sendToPeer(peerId, event);
|
||||
}
|
||||
|
||||
async sendToPeerBuffered(peerId: string, event: ChatEvent): Promise<void> {
|
||||
@@ -101,6 +106,10 @@ export class PeerMediaFacade {
|
||||
return this.dependencies.mediaManager.getRawMicStream();
|
||||
}
|
||||
|
||||
mayOpenVoicePathToPeer(peerId: string): boolean {
|
||||
return this.dependencies.mediaManager.mayOpenVoicePathToPeer(peerId);
|
||||
}
|
||||
|
||||
isScreenShareActive(): boolean {
|
||||
return this.dependencies.screenShareManager.getIsScreenActive();
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user