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:
@@ -181,7 +181,8 @@
|
||||
"microphone": "Microphone",
|
||||
"speaker": "Speaker",
|
||||
"microphoneFallback": "Microphone {{index}}",
|
||||
"speakerFallback": "Speaker {{index}}"
|
||||
"speakerFallback": "Speaker {{index}}",
|
||||
"systemDefault": "System default"
|
||||
},
|
||||
"volume": {
|
||||
"title": "Volume",
|
||||
|
||||
@@ -7,6 +7,10 @@
|
||||
"retry": "Retry",
|
||||
"failedConnect": "Failed to connect voice session."
|
||||
},
|
||||
"devices": {
|
||||
"inputFellBack": "Your microphone was disconnected. Switched to the system default.",
|
||||
"outputFellBack": "Your speaker was disconnected. Switched to the system default."
|
||||
},
|
||||
"floating": {
|
||||
"backToServer": "Back to {{server}}",
|
||||
"voiceFallback": "Voice",
|
||||
|
||||
+8
@@ -81,6 +81,10 @@ export class VoiceConnectionFacade {
|
||||
await this.realtime.setLocalStream(stream);
|
||||
}
|
||||
|
||||
async switchInputDevice(deviceId: string): Promise<MediaStream | null> {
|
||||
return await this.realtime.switchInputDevice(deviceId);
|
||||
}
|
||||
|
||||
toggleMute(muted?: boolean): void {
|
||||
this.realtime.toggleMute(muted);
|
||||
}
|
||||
@@ -120,4 +124,8 @@ export class VoiceConnectionFacade {
|
||||
syncOutgoingVoiceRouting(allowedPeerIds: string[]): void {
|
||||
this.realtime.syncOutgoingVoiceRouting(allowedPeerIds);
|
||||
}
|
||||
|
||||
mayHearPeerVoice(peerId: string, hasEstablishedPlayback: boolean): boolean {
|
||||
return this.realtime.mayHearPeerVoice(peerId, hasEstablishedPlayback);
|
||||
}
|
||||
}
|
||||
|
||||
+20
-1
@@ -27,7 +27,7 @@ import {
|
||||
import { Subscription } from 'rxjs';
|
||||
import { VoiceConnectionFacade } from '../facades/voice-connection.facade';
|
||||
import { DebuggingService } from '../../../../core/services/debugging.service';
|
||||
/* eslint-disable @typescript-eslint/member-ordering, @typescript-eslint/prefer-for-of, max-statements-per-line */
|
||||
/* eslint-disable @typescript-eslint/prefer-for-of, max-statements-per-line */
|
||||
|
||||
const SPEAKING_THRESHOLD = 0.015;
|
||||
const SILENT_FRAME_GRACE = 8;
|
||||
@@ -50,6 +50,8 @@ export class VoiceActivityService implements OnDestroy {
|
||||
private readonly debugging = inject(DebuggingService);
|
||||
|
||||
private readonly tracked = new Map<string, TrackedStream>();
|
||||
/** The id the local microphone is tracked under, so it can follow a device switch. */
|
||||
private localMicUserId: string | null = null;
|
||||
private animFrameId: number | null = null;
|
||||
private readonly subs: Subscription[] = [];
|
||||
private readonly _speakingMap = signal<ReadonlyMap<string, boolean>>(new Map());
|
||||
@@ -84,13 +86,30 @@ export class VoiceActivityService implements OnDestroy {
|
||||
}
|
||||
|
||||
trackLocalMic(userId: string, stream: MediaStream): void {
|
||||
this.localMicUserId = userId;
|
||||
this.trackStream(userId, stream);
|
||||
}
|
||||
|
||||
untrackLocalMic(userId: string): void {
|
||||
if (this.localMicUserId === userId) {
|
||||
this.localMicUserId = null;
|
||||
}
|
||||
|
||||
this.untrackStream(userId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Point the local speaking indicator at a replacement microphone stream,
|
||||
* so switching devices mid-call does not leave it watching a dead track.
|
||||
*/
|
||||
refreshLocalMicStream(stream: MediaStream): void {
|
||||
if (!this.localMicUserId) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.trackStream(this.localMicUserId, stream);
|
||||
}
|
||||
|
||||
isSpeaking(userId: string): Signal<boolean> {
|
||||
const entry = this.tracked.get(userId);
|
||||
|
||||
|
||||
+30
-2
@@ -13,10 +13,12 @@ import { VoiceConnectionFacade } from '../facades/voice-connection.facade';
|
||||
import { VoicePlaybackService } from './voice-playback.service';
|
||||
|
||||
let audioContextCount = 0;
|
||||
let createdGainNodes: { gain: { value: number } }[] = [];
|
||||
|
||||
describe('VoicePlaybackService', () => {
|
||||
beforeEach(() => {
|
||||
audioContextCount = 0;
|
||||
createdGainNodes = [];
|
||||
installAudioDomMocks();
|
||||
installLocalStorageMock();
|
||||
});
|
||||
@@ -94,6 +96,27 @@ describe('VoicePlaybackService', () => {
|
||||
expect(audioContextCount).toBe(1);
|
||||
expect(context.service.getUserVolume('peer-1')).toBe(100);
|
||||
});
|
||||
|
||||
// The roster is gossip. A peer we already receive audio from stays audible unless the
|
||||
// voice session has positive evidence it left, otherwise a dropped socket silences a
|
||||
// member who never went anywhere.
|
||||
it('keeps a peer audible while the voice session holds the path open', () => {
|
||||
const context = createServiceContext({ isVoiceConnected: true });
|
||||
|
||||
context.service.handleRemoteStream('peer-1', createMockAudioStream(['track-a']), connectedOptions());
|
||||
|
||||
expect(context.voiceConnection.mayHearPeerVoice).toHaveBeenCalledWith('peer-1', true);
|
||||
expect(createdGainNodes.at(-1)?.gain.value).toBe(1);
|
||||
});
|
||||
|
||||
it('mutes a peer the voice session reports as gone from our channel', () => {
|
||||
const context = createServiceContext({ isVoiceConnected: true });
|
||||
|
||||
context.voiceConnection.mayHearPeerVoice.mockReturnValue(false);
|
||||
context.service.handleRemoteStream('peer-1', createMockAudioStream(['track-a']), connectedOptions());
|
||||
|
||||
expect(createdGainNodes.at(-1)?.gain.value).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
interface ServiceContext {
|
||||
@@ -106,6 +129,7 @@ interface ServiceContext {
|
||||
getRemoteVoiceStream: ReturnType<typeof vi.fn>;
|
||||
getConnectedPeers: ReturnType<typeof vi.fn>;
|
||||
syncOutgoingVoiceRouting: ReturnType<typeof vi.fn>;
|
||||
mayHearPeerVoice: ReturnType<typeof vi.fn>;
|
||||
};
|
||||
remoteStream$: Subject<{ peerId: string; stream: MediaStream }>;
|
||||
}
|
||||
@@ -119,7 +143,8 @@ function createServiceContext(options: { isVoiceConnected?: boolean } = {}): Ser
|
||||
onPeerDisconnected: new Subject<string>(),
|
||||
getRemoteVoiceStream: vi.fn(() => null),
|
||||
getConnectedPeers: vi.fn(() => []),
|
||||
syncOutgoingVoiceRouting: vi.fn()
|
||||
syncOutgoingVoiceRouting: vi.fn(),
|
||||
mayHearPeerVoice: vi.fn(() => true)
|
||||
};
|
||||
const screenShare = {
|
||||
isScreenShareRemotePlaybackSuppressed: signal(false),
|
||||
@@ -237,10 +262,13 @@ function installAudioDomMocks(): void {
|
||||
}
|
||||
|
||||
createGain() {
|
||||
return {
|
||||
const gainNode = {
|
||||
gain: { value: 0 },
|
||||
connect: vi.fn()
|
||||
};
|
||||
|
||||
createdGainNodes.push(gainNode);
|
||||
return gainNode;
|
||||
}
|
||||
|
||||
createMediaStreamSource() {
|
||||
|
||||
+12
-18
@@ -7,7 +7,6 @@ import { Store } from '@ngrx/store';
|
||||
import { STORAGE_KEY_USER_VOLUMES } from '../../../../core/constants';
|
||||
import { jsonStorage } from '../../../../infrastructure/persistence/json-storage.service';
|
||||
import { ScreenShareFacade } from '../../../../domains/screen-share';
|
||||
import { User } from '../../../../shared-kernel';
|
||||
import { selectAllUsers, selectCurrentUser } from '../../../../store/users/users.selectors';
|
||||
import { VoiceConnectionFacade } from '../facades/voice-connection.facade';
|
||||
|
||||
@@ -331,7 +330,7 @@ export class VoicePlaybackService {
|
||||
if (!pipeline)
|
||||
return;
|
||||
|
||||
if (this.deafened || this.captureEchoSuppressed || this.isUserMuted(peerId) || !this.isPeerInCurrentVoiceRoom(peerId)) {
|
||||
if (this.deafened || this.captureEchoSuppressed || this.isUserMuted(peerId) || !this.mayHearPeer(peerId)) {
|
||||
pipeline.gainNode.gain.value = 0;
|
||||
return;
|
||||
}
|
||||
@@ -346,22 +345,17 @@ export class VoicePlaybackService {
|
||||
this.peerPipelines.forEach((_pipeline, peerId) => this.applyGain(peerId));
|
||||
}
|
||||
|
||||
private isPeerInCurrentVoiceRoom(peerId: string): boolean {
|
||||
const localVoiceState = this.currentUser()?.voiceState;
|
||||
|
||||
if (!localVoiceState?.isConnected || !localVoiceState.roomId || !localVoiceState.serverId) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const remoteVoiceState = this.findUserForPeer(peerId)?.voiceState;
|
||||
|
||||
return !!remoteVoiceState?.isConnected
|
||||
&& remoteVoiceState.roomId === localVoiceState.roomId
|
||||
&& remoteVoiceState.serverId === localVoiceState.serverId;
|
||||
}
|
||||
|
||||
private findUserForPeer(peerId: string): User | undefined {
|
||||
return this.allUsers().find((user) => user.id === peerId || user.oderId === peerId || user.peerId === peerId);
|
||||
/**
|
||||
* Whether this peer's audio may be audible.
|
||||
*
|
||||
* The roster is gossip: the signal server broadcasts `user_left` for any socket it
|
||||
* declares dead, which used to mute a peer still sitting in our channel. Playback
|
||||
* therefore asks the voice session, which weighs the roster against what the peer
|
||||
* itself reported over its data channel, and holds a stream we already receive when
|
||||
* neither confirms nor denies.
|
||||
*/
|
||||
private mayHearPeer(peerId: string): boolean {
|
||||
return this.voiceConnection.mayHearPeerVoice(peerId, this.rawRemoteStreams.has(peerId));
|
||||
}
|
||||
|
||||
private syncOutgoingVoiceRouting(): void {
|
||||
|
||||
@@ -12,10 +12,14 @@ voice-session/
|
||||
│ ├── facades/
|
||||
│ │ └── voice-session.facade.ts Tracks active voice session, drives floating controls
|
||||
│ └── services/
|
||||
│ ├── voice-audio-device.service.ts Microphone/speaker selection, live apply, devicechange fallback
|
||||
│ └── voice-workspace.service.ts Workspace mode (hidden/expanded/minimized), focused stream, mini-window position
|
||||
│
|
||||
├── domain/
|
||||
│ ├── logic/
|
||||
│ │ ├── audio-device-selection.rules.ts Device fallback decisions + getUserMedia constraints
|
||||
│ │ ├── stream-indicator.rules.ts Whether a user's LIVE indicator shows
|
||||
│ │ ├── voice-path-routing.rules.ts Whether a voice path with a peer may carry audio
|
||||
│ │ └── voice-session.logic.ts isViewingVoiceSessionServer, buildVoiceSessionRoom
|
||||
│ └── models/
|
||||
│ └── voice-session.model.ts VoiceSessionInfo interface
|
||||
@@ -92,6 +96,12 @@ Each install has a stable `clientInstanceId` (`ClientInstanceService`). `VoiceSt
|
||||
|
||||
Rules live in `domain/logic/client-voice-session.rules.ts`.
|
||||
|
||||
Every media path to a peer — the microphone we send, the camera we send, and the audio we play back — is decided per peer by `decideVoicePathRouting` (`domain/logic/voice-path-routing.rules.ts`). `MediaManager.syncVoiceRouting()` and `syncCameraRouting()` call it on every routing pass, `mayHearPeerVoice()` reuses it for playback gain, and `mayOpenVoicePathToPeer()` answers the same question for a peer connection being built, so `createPeerConnection` cannot put the microphone in a first offer that routing would refuse. A peer therefore cannot be audible while muted-by-routing, or keep our camera after losing our microphone, or start receiving either one because a connection happened to be created while we were in voice.
|
||||
|
||||
Presence in the roster or the peer's own `voice-state` message opens a path; only positive evidence closes one — we left voice, the peer said it is not in our channel, or the peer connection is closed. Everything else is `hold`: an already-negotiated path stays up. A peer missing from the roster is not evidence it left voice, because the signal server broadcasts `user_left` for any socket it declares dead; treating that as a departure used to detach the microphone from a peer still sitting in the channel, leaving one side of the call permanently silent. A path that was never negotiated still stays closed until something confirms the peer, so a guess can never start sending the microphone. When the peer and the roster disagree, the peer's own report wins unless it is older than the roster claim.
|
||||
|
||||
Regression cover: `voice-path-routing.rules.spec.ts`, the routing, first-offer, and camera cases in `media.manager.spec.ts`, the gate case in `create-peer-connection.spec.ts`, and `e2e/tests/voice/roster-loss-preserves-voice.spec.ts`.
|
||||
|
||||
Remote voice playback is scoped to the active voice channel, not the whole server. Users stay connected to the shared peer mesh for text, presence, and screen-share control, but voice transport and playback only stay active for peers whose `voiceState.roomId` and `voiceState.serverId` match the local user's current voice session.
|
||||
|
||||
Owners and admins can also move connected users between voice channels from the room sidebar by dragging a user onto a different voice channel. The moved client updates its local heartbeat and voice-session metadata to the new channel, so routing, floating controls, and occupancy stay in sync after the move.
|
||||
@@ -112,6 +122,8 @@ stateDiagram-v2
|
||||
Minimized --> Hidden: voice session ends
|
||||
```
|
||||
|
||||
A user's LIVE indicator is decided by `shouldShowStreamIndicator` (`domain/logic/stream-indicator.rules.ts`) from the observed user's state alone — whether they are in a voice channel, what they announced over the peer plane, and any live track. The observer's own voice session is deliberately not part of that decision, so someone sharing alone is visible to everyone in the server; clicking the badge from outside the channel joins that channel first and then focuses the stream.
|
||||
|
||||
The minimized mode renders a draggable mini-window. Its position is tracked in `miniWindowPosition` and clamped to viewport bounds on resize. `focusedStreamId` controls which live stream gets the widescreen treatment in expanded mode, using feature-level stream IDs such as `screen:<peerKey>` or `camera:<peerKey>`.
|
||||
|
||||
## Voice settings
|
||||
@@ -132,3 +144,12 @@ Settings are stored in localStorage under a single JSON key. All values are vali
|
||||
| includeSystemAudio | `false` | boolean |
|
||||
|
||||
`loadVoiceSettingsFromStorage()` and `saveVoiceSettingsToStorage(patch)` are the only entry points. The save function merges the patch with the current stored value so callers only need to pass changed fields.
|
||||
|
||||
## Audio devices: one owner, applied live
|
||||
|
||||
`VoiceAudioDeviceService` owns `inputDevice` and `outputDevice`. Every surface that offers a picker — the settings modal today — calls it instead of writing storage itself, so a picker cannot be live in one place and inert in another.
|
||||
|
||||
- **Changing the microphone never leaves voice.** The service calls `VoiceConnectionFacade.switchInputDevice()`, which re-captures the mic and swaps the track into the existing peer senders via `replaceTrack`. There is no disconnect, no rejoin broadcast, and no SDP renegotiation, so remote peers keep the same audio track. Proven by `e2e/tests/voice/live-input-device-change.spec.ts`.
|
||||
- **Changing the speaker** re-applies the sink to every live playback pipeline (`VoicePlaybackService.applyOutputDevice`).
|
||||
- **A device disappearing** falls back to the system default and sets `deviceNotice` (a translation key) for the UI. An empty device list is treated as missing evidence, never as an unplugged device: browsers report no devices before the microphone permission is granted, and Firefox never enumerates audio outputs.
|
||||
- **Mute and deafen are not owned here.** `MediaManager` holds them and the state reaches the UI through `VoiceConnectionFacade.isMuted` / `isDeafened`. Components must read those signals rather than keeping a local copy, otherwise two control surfaces disagree after a swap or a rejoin.
|
||||
|
||||
+198
@@ -0,0 +1,198 @@
|
||||
import { Injector, runInInjectionContext } from '@angular/core';
|
||||
import {
|
||||
afterEach,
|
||||
beforeEach,
|
||||
describe,
|
||||
expect,
|
||||
it,
|
||||
vi
|
||||
} from 'vitest';
|
||||
import { VoiceAudioDeviceService } from './voice-audio-device.service';
|
||||
import {
|
||||
VoiceActivityService,
|
||||
VoiceConnectionFacade,
|
||||
VoicePlaybackService
|
||||
} from '../../../voice-connection';
|
||||
import { saveVoiceSettingsToStorage, loadVoiceSettingsFromStorage } from '../../infrastructure/util/voice-settings-storage.util';
|
||||
|
||||
const BUILT_IN_MIC = { kind: 'audioinput', deviceId: 'default', label: 'Built-in Mic' };
|
||||
const HEADSET_MIC = { kind: 'audioinput', deviceId: 'headset', label: 'Headset' };
|
||||
const SPEAKERS = { kind: 'audiooutput', deviceId: 'speakers', label: 'Speakers' };
|
||||
const HDMI_OUTPUT = { kind: 'audiooutput', deviceId: 'hdmi', label: 'HDMI Output' };
|
||||
|
||||
interface DeviceHarness {
|
||||
service: VoiceAudioDeviceService;
|
||||
voiceConnection: {
|
||||
isVoiceConnected: ReturnType<typeof vi.fn>;
|
||||
switchInputDevice: ReturnType<typeof vi.fn>;
|
||||
};
|
||||
voicePlayback: { applyOutputDevice: ReturnType<typeof vi.fn> };
|
||||
voiceActivity: { refreshLocalMicStream: ReturnType<typeof vi.fn> };
|
||||
emitDeviceChange: () => Promise<void>;
|
||||
setDevices: (devices: { kind: string; deviceId: string; label: string }[]) => void;
|
||||
}
|
||||
|
||||
describe('VoiceAudioDeviceService', () => {
|
||||
let harness: DeviceHarness;
|
||||
|
||||
beforeEach(() => {
|
||||
harness = createHarness();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
it('lists the microphones and speakers the browser reports', async () => {
|
||||
await harness.service.refreshDevices();
|
||||
|
||||
expect(harness.service.inputDevices().map((device) => device.deviceId)).toEqual(['default', 'headset']);
|
||||
expect(harness.service.outputDevices().map((device) => device.deviceId)).toEqual(['speakers']);
|
||||
});
|
||||
|
||||
it('switches the live microphone instead of rejoining voice', async () => {
|
||||
harness.voiceConnection.isVoiceConnected.mockReturnValue(true);
|
||||
|
||||
await harness.service.selectInputDevice('headset');
|
||||
|
||||
expect(harness.voiceConnection.switchInputDevice).toHaveBeenCalledWith('headset');
|
||||
expect(harness.service.selectedInputDeviceId()).toBe('headset');
|
||||
expect(loadVoiceSettingsFromStorage().inputDevice).toBe('headset');
|
||||
});
|
||||
|
||||
it('keeps the local speaking indicator on the new microphone stream', async () => {
|
||||
const replacementStream = { id: 'replacement' } as MediaStream;
|
||||
|
||||
harness.voiceConnection.isVoiceConnected.mockReturnValue(true);
|
||||
harness.voiceConnection.switchInputDevice.mockResolvedValue(replacementStream);
|
||||
|
||||
await harness.service.selectInputDevice('headset');
|
||||
|
||||
expect(harness.voiceActivity.refreshLocalMicStream).toHaveBeenCalledWith(replacementStream);
|
||||
});
|
||||
|
||||
it('applies a speaker choice to the live playback pipelines', async () => {
|
||||
await harness.service.selectOutputDevice('speakers');
|
||||
|
||||
expect(harness.voicePlayback.applyOutputDevice).toHaveBeenCalledWith('speakers');
|
||||
expect(loadVoiceSettingsFromStorage().outputDevice).toBe('speakers');
|
||||
});
|
||||
|
||||
it('falls back to the system default when the selected microphone is unplugged', async () => {
|
||||
harness.voiceConnection.isVoiceConnected.mockReturnValue(true);
|
||||
|
||||
await harness.service.selectInputDevice('headset');
|
||||
harness.voiceConnection.switchInputDevice.mockClear();
|
||||
|
||||
harness.setDevices([BUILT_IN_MIC, SPEAKERS]);
|
||||
|
||||
await harness.emitDeviceChange();
|
||||
|
||||
expect(harness.voiceConnection.switchInputDevice).toHaveBeenCalledWith('');
|
||||
expect(harness.service.selectedInputDeviceId()).toBe('');
|
||||
expect(harness.service.deviceNotice()).toBe('voice.devices.inputFellBack');
|
||||
});
|
||||
|
||||
it('falls back to the system default when the selected speaker is unplugged', async () => {
|
||||
await harness.service.selectOutputDevice('speakers');
|
||||
harness.voicePlayback.applyOutputDevice.mockClear();
|
||||
|
||||
harness.setDevices([BUILT_IN_MIC, HDMI_OUTPUT]);
|
||||
|
||||
await harness.emitDeviceChange();
|
||||
|
||||
expect(harness.voicePlayback.applyOutputDevice).toHaveBeenCalledWith('');
|
||||
expect(harness.service.deviceNotice()).toBe('voice.devices.outputFellBack');
|
||||
});
|
||||
|
||||
it('does not report a speaker fallback when the browser lists no speakers at all', async () => {
|
||||
await harness.service.selectOutputDevice('speakers');
|
||||
harness.voicePlayback.applyOutputDevice.mockClear();
|
||||
|
||||
harness.setDevices([BUILT_IN_MIC]);
|
||||
|
||||
await harness.emitDeviceChange();
|
||||
|
||||
expect(harness.voicePlayback.applyOutputDevice).not.toHaveBeenCalled();
|
||||
expect(harness.service.deviceNotice()).toBeNull();
|
||||
});
|
||||
|
||||
it('leaves a selection alone when a device list arrives empty', async () => {
|
||||
harness.voiceConnection.isVoiceConnected.mockReturnValue(true);
|
||||
|
||||
await harness.service.selectInputDevice('headset');
|
||||
harness.voiceConnection.switchInputDevice.mockClear();
|
||||
|
||||
harness.setDevices([]);
|
||||
|
||||
await harness.emitDeviceChange();
|
||||
|
||||
expect(harness.voiceConnection.switchInputDevice).not.toHaveBeenCalled();
|
||||
expect(harness.service.selectedInputDeviceId()).toBe('headset');
|
||||
expect(harness.service.deviceNotice()).toBeNull();
|
||||
});
|
||||
|
||||
it('records a device choice made before joining voice without capturing', async () => {
|
||||
harness.voiceConnection.isVoiceConnected.mockReturnValue(false);
|
||||
|
||||
await harness.service.selectInputDevice('headset');
|
||||
|
||||
expect(harness.voiceConnection.switchInputDevice).not.toHaveBeenCalled();
|
||||
expect(loadVoiceSettingsFromStorage().inputDevice).toBe('headset');
|
||||
});
|
||||
});
|
||||
|
||||
function createHarness(): DeviceHarness {
|
||||
saveVoiceSettingsToStorage({ inputDevice: '', outputDevice: '' });
|
||||
|
||||
let devices = [
|
||||
BUILT_IN_MIC,
|
||||
HEADSET_MIC,
|
||||
SPEAKERS
|
||||
];
|
||||
|
||||
const listeners: (() => void)[] = [];
|
||||
|
||||
vi.stubGlobal('navigator', {
|
||||
mediaDevices: {
|
||||
enumerateDevices: vi.fn(async () => devices),
|
||||
addEventListener: vi.fn((type: string, listener: () => void) => {
|
||||
if (type === 'devicechange') {
|
||||
listeners.push(listener);
|
||||
}
|
||||
}),
|
||||
removeEventListener: vi.fn()
|
||||
}
|
||||
});
|
||||
|
||||
const voiceConnection = {
|
||||
isVoiceConnected: vi.fn(() => false),
|
||||
switchInputDevice: vi.fn(async () => null)
|
||||
};
|
||||
const voicePlayback = { applyOutputDevice: vi.fn() };
|
||||
const voiceActivity = { refreshLocalMicStream: vi.fn() };
|
||||
const injector = Injector.create({
|
||||
providers: [
|
||||
{ provide: VoiceConnectionFacade, useValue: voiceConnection },
|
||||
{ provide: VoicePlaybackService, useValue: voicePlayback },
|
||||
{ provide: VoiceActivityService, useValue: voiceActivity }
|
||||
]
|
||||
});
|
||||
const service = runInInjectionContext(injector, () => new VoiceAudioDeviceService());
|
||||
|
||||
return {
|
||||
service,
|
||||
voiceConnection,
|
||||
voicePlayback,
|
||||
voiceActivity,
|
||||
emitDeviceChange: async () => {
|
||||
listeners.forEach((listener) => listener());
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
},
|
||||
setDevices: (next) => {
|
||||
devices = next;
|
||||
}
|
||||
};
|
||||
}
|
||||
+167
@@ -0,0 +1,167 @@
|
||||
/**
|
||||
* Owns the audio-device selection for voice: which microphone and speaker the
|
||||
* user picked, persisting it, and applying it to a live session.
|
||||
*
|
||||
* Both the in-channel voice controls and the settings modal go through this
|
||||
* service, so a picker cannot be a dead control in one surface and live in the
|
||||
* other, and a device change never costs the user their voice session.
|
||||
*/
|
||||
import {
|
||||
Injectable,
|
||||
OnDestroy,
|
||||
computed,
|
||||
inject,
|
||||
signal
|
||||
} from '@angular/core';
|
||||
import {
|
||||
VoiceActivityService,
|
||||
VoiceConnectionFacade,
|
||||
VoicePlaybackService
|
||||
} from '../../../voice-connection';
|
||||
import { SYSTEM_DEFAULT_AUDIO_DEVICE_ID, resolveAudioDeviceSelection } from '../../domain/logic/audio-device-selection.rules';
|
||||
import { loadVoiceSettingsFromStorage, saveVoiceSettingsToStorage } from '../../infrastructure/util/voice-settings-storage.util';
|
||||
|
||||
export interface AudioDeviceOption {
|
||||
deviceId: string;
|
||||
label: string;
|
||||
}
|
||||
|
||||
/** Translation keys shown when a device disappears underneath the user. */
|
||||
export const INPUT_FALLBACK_NOTICE = 'voice.devices.inputFellBack';
|
||||
export const OUTPUT_FALLBACK_NOTICE = 'voice.devices.outputFellBack';
|
||||
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class VoiceAudioDeviceService implements OnDestroy {
|
||||
private readonly voiceConnection = inject(VoiceConnectionFacade);
|
||||
private readonly voicePlayback = inject(VoicePlaybackService);
|
||||
private readonly voiceActivity = inject(VoiceActivityService);
|
||||
|
||||
private readonly _inputDevices = signal<AudioDeviceOption[]>([]);
|
||||
private readonly _outputDevices = signal<AudioDeviceOption[]>([]);
|
||||
private readonly _selectedInputDeviceId = signal(SYSTEM_DEFAULT_AUDIO_DEVICE_ID);
|
||||
private readonly _selectedOutputDeviceId = signal(SYSTEM_DEFAULT_AUDIO_DEVICE_ID);
|
||||
private readonly _deviceNotice = signal<string | null>(null);
|
||||
|
||||
readonly inputDevices = computed(() => this._inputDevices());
|
||||
readonly outputDevices = computed(() => this._outputDevices());
|
||||
readonly selectedInputDeviceId = computed(() => this._selectedInputDeviceId());
|
||||
readonly selectedOutputDeviceId = computed(() => this._selectedOutputDeviceId());
|
||||
/** A translation key when a selected device vanished, otherwise `null`. */
|
||||
readonly deviceNotice = computed(() => this._deviceNotice());
|
||||
|
||||
private readonly handleDeviceChange = (): void => {
|
||||
void this.refreshDevices({ reconcileSelection: true });
|
||||
};
|
||||
|
||||
constructor() {
|
||||
const settings = loadVoiceSettingsFromStorage();
|
||||
|
||||
this._selectedInputDeviceId.set(settings.inputDevice);
|
||||
this._selectedOutputDeviceId.set(settings.outputDevice);
|
||||
|
||||
navigator.mediaDevices?.addEventListener?.('devicechange', this.handleDeviceChange);
|
||||
}
|
||||
|
||||
ngOnDestroy(): void {
|
||||
navigator.mediaDevices?.removeEventListener?.('devicechange', this.handleDeviceChange);
|
||||
}
|
||||
|
||||
/** Re-read the browser device lists, optionally repairing a stale selection. */
|
||||
async refreshDevices(options: { reconcileSelection?: boolean } = {}): Promise<void> {
|
||||
if (!navigator.mediaDevices?.enumerateDevices) {
|
||||
return;
|
||||
}
|
||||
|
||||
let devices: MediaDeviceInfo[];
|
||||
|
||||
try {
|
||||
devices = await navigator.mediaDevices.enumerateDevices();
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
|
||||
this._inputDevices.set(toDeviceOptions(devices, 'audioinput'));
|
||||
this._outputDevices.set(toDeviceOptions(devices, 'audiooutput'));
|
||||
|
||||
if (options.reconcileSelection) {
|
||||
await this.reconcileSelection();
|
||||
}
|
||||
}
|
||||
|
||||
/** Pick a microphone, applying it to a live session when there is one. */
|
||||
async selectInputDevice(deviceId: string): Promise<void> {
|
||||
this._deviceNotice.set(null);
|
||||
await this.applyInputDevice(deviceId);
|
||||
}
|
||||
|
||||
/** Pick a speaker, applying it to the live playback pipelines. */
|
||||
async selectOutputDevice(deviceId: string): Promise<void> {
|
||||
this._deviceNotice.set(null);
|
||||
this.applyOutputDevice(deviceId);
|
||||
}
|
||||
|
||||
/** Re-apply the stored speaker choice to the live playback pipelines. */
|
||||
applySelectedOutputDevice(): void {
|
||||
this.voicePlayback.applyOutputDevice(this._selectedOutputDeviceId());
|
||||
}
|
||||
|
||||
dismissDeviceNotice(): void {
|
||||
this._deviceNotice.set(null);
|
||||
}
|
||||
|
||||
private async applyInputDevice(deviceId: string): Promise<void> {
|
||||
this._selectedInputDeviceId.set(deviceId);
|
||||
saveVoiceSettingsToStorage({ inputDevice: deviceId });
|
||||
|
||||
if (!this.voiceConnection.isVoiceConnected()) {
|
||||
return;
|
||||
}
|
||||
|
||||
const stream = await this.voiceConnection.switchInputDevice(deviceId);
|
||||
|
||||
if (stream) {
|
||||
this.voiceActivity.refreshLocalMicStream(stream);
|
||||
}
|
||||
}
|
||||
|
||||
private applyOutputDevice(deviceId: string): void {
|
||||
this._selectedOutputDeviceId.set(deviceId);
|
||||
saveVoiceSettingsToStorage({ outputDevice: deviceId });
|
||||
this.voicePlayback.applyOutputDevice(deviceId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Repair selections whose device is gone. An empty device list is treated as
|
||||
* missing evidence, not as an unplugged device.
|
||||
*/
|
||||
private async reconcileSelection(): Promise<void> {
|
||||
const input = resolveAudioDeviceSelection({
|
||||
selectedDeviceId: this._selectedInputDeviceId(),
|
||||
availableDeviceIds: this._inputDevices().map((device) => device.deviceId)
|
||||
});
|
||||
const output = resolveAudioDeviceSelection({
|
||||
selectedDeviceId: this._selectedOutputDeviceId(),
|
||||
availableDeviceIds: this._outputDevices().map((device) => device.deviceId)
|
||||
});
|
||||
|
||||
if (input.didFallBack) {
|
||||
await this.applyInputDevice(input.deviceId);
|
||||
this._deviceNotice.set(INPUT_FALLBACK_NOTICE);
|
||||
}
|
||||
|
||||
if (output.didFallBack) {
|
||||
this.applyOutputDevice(output.deviceId);
|
||||
this._deviceNotice.set(OUTPUT_FALLBACK_NOTICE);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Devices without an id cannot be requested, and the empty id already means
|
||||
* "system default" in the picker, so they are dropped.
|
||||
*/
|
||||
function toDeviceOptions(devices: MediaDeviceInfo[], kind: MediaDeviceKind): AudioDeviceOption[] {
|
||||
return devices
|
||||
.filter((device) => device.kind === kind && !!device.deviceId)
|
||||
.map((device) => ({ deviceId: device.deviceId, label: device.label }));
|
||||
}
|
||||
+109
@@ -0,0 +1,109 @@
|
||||
import {
|
||||
describe,
|
||||
expect,
|
||||
it
|
||||
} from 'vitest';
|
||||
import {
|
||||
SYSTEM_DEFAULT_AUDIO_DEVICE_ID,
|
||||
buildMicrophoneConstraints,
|
||||
isDeviceUnavailableError,
|
||||
resolveAudioDeviceSelection
|
||||
} from './audio-device-selection.rules';
|
||||
|
||||
describe('resolveAudioDeviceSelection', () => {
|
||||
it('keeps a selection that is still present in the device list', () => {
|
||||
const outcome = resolveAudioDeviceSelection({
|
||||
selectedDeviceId: 'headset',
|
||||
availableDeviceIds: [
|
||||
'default',
|
||||
'headset',
|
||||
'webcam-mic'
|
||||
]
|
||||
});
|
||||
|
||||
expect(outcome).toEqual({ deviceId: 'headset', didFallBack: false });
|
||||
});
|
||||
|
||||
it('reports the system default when nothing is selected', () => {
|
||||
const outcome = resolveAudioDeviceSelection({
|
||||
selectedDeviceId: SYSTEM_DEFAULT_AUDIO_DEVICE_ID,
|
||||
availableDeviceIds: ['default', 'headset']
|
||||
});
|
||||
|
||||
expect(outcome).toEqual({ deviceId: SYSTEM_DEFAULT_AUDIO_DEVICE_ID, didFallBack: false });
|
||||
});
|
||||
|
||||
it('falls back to the system default when the selected device disappeared', () => {
|
||||
const outcome = resolveAudioDeviceSelection({
|
||||
selectedDeviceId: 'headset',
|
||||
availableDeviceIds: ['default', 'webcam-mic']
|
||||
});
|
||||
|
||||
expect(outcome).toEqual({ deviceId: SYSTEM_DEFAULT_AUDIO_DEVICE_ID, didFallBack: true });
|
||||
});
|
||||
|
||||
it('keeps the selection when the device list is empty, because an empty list is no evidence', () => {
|
||||
const outcome = resolveAudioDeviceSelection({
|
||||
selectedDeviceId: 'headset',
|
||||
availableDeviceIds: []
|
||||
});
|
||||
|
||||
expect(outcome).toEqual({ deviceId: 'headset', didFallBack: false });
|
||||
});
|
||||
|
||||
it('keeps the selection when the list only holds unlabelled placeholder ids', () => {
|
||||
const outcome = resolveAudioDeviceSelection({
|
||||
selectedDeviceId: 'headset',
|
||||
availableDeviceIds: ['', '']
|
||||
});
|
||||
|
||||
expect(outcome).toEqual({ deviceId: 'headset', didFallBack: false });
|
||||
});
|
||||
});
|
||||
|
||||
describe('buildMicrophoneConstraints', () => {
|
||||
// A bare id is only a preference: the browser may return the current default
|
||||
// instead, which makes the device picker look broken on real hardware.
|
||||
it('requires the chosen device exactly', () => {
|
||||
const constraints = buildMicrophoneConstraints({
|
||||
deviceId: 'headset',
|
||||
browserNoiseSuppression: false
|
||||
});
|
||||
|
||||
expect(constraints.audio).toMatchObject({ deviceId: { exact: 'headset' } });
|
||||
expect(constraints.video).toBe(false);
|
||||
});
|
||||
|
||||
it('omits deviceId entirely when following the system default', () => {
|
||||
const constraints = buildMicrophoneConstraints({
|
||||
deviceId: SYSTEM_DEFAULT_AUDIO_DEVICE_ID,
|
||||
browserNoiseSuppression: true
|
||||
});
|
||||
|
||||
expect(constraints.audio).not.toHaveProperty('deviceId');
|
||||
});
|
||||
|
||||
it('passes the browser noise-suppression choice through', () => {
|
||||
expect(
|
||||
buildMicrophoneConstraints({ deviceId: '', browserNoiseSuppression: true }).audio
|
||||
).toMatchObject({ noiseSuppression: true, echoCancellation: true, autoGainControl: true });
|
||||
|
||||
expect(
|
||||
buildMicrophoneConstraints({ deviceId: '', browserNoiseSuppression: false }).audio
|
||||
).toMatchObject({ noiseSuppression: false });
|
||||
});
|
||||
});
|
||||
|
||||
describe('isDeviceUnavailableError', () => {
|
||||
it('recognises the errors that mean the exact device cannot be used', () => {
|
||||
expect(isDeviceUnavailableError({ name: 'OverconstrainedError' })).toBe(true);
|
||||
expect(isDeviceUnavailableError({ name: 'NotFoundError' })).toBe(true);
|
||||
});
|
||||
|
||||
// A denied permission must keep failing instead of silently opening another mic.
|
||||
it('does not treat a denied permission or an unknown failure as a missing device', () => {
|
||||
expect(isDeviceUnavailableError({ name: 'NotAllowedError' })).toBe(false);
|
||||
expect(isDeviceUnavailableError(new Error('boom'))).toBe(false);
|
||||
expect(isDeviceUnavailableError(null)).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,91 @@
|
||||
/**
|
||||
* Pure decisions about which audio device to capture from or play to.
|
||||
*
|
||||
* The browser device list is unreliable evidence: it is empty before the
|
||||
* microphone permission is granted and can hold unlabelled placeholder
|
||||
* entries, so "my device is not in the list" only means the device is gone
|
||||
* when the list itself is trustworthy.
|
||||
*/
|
||||
|
||||
/** The stored value that means "follow whatever the operating system picked". */
|
||||
export const SYSTEM_DEFAULT_AUDIO_DEVICE_ID = '';
|
||||
|
||||
export interface AudioDeviceSelectionInput {
|
||||
/** The user's saved choice, or {@link SYSTEM_DEFAULT_AUDIO_DEVICE_ID}. */
|
||||
readonly selectedDeviceId: string;
|
||||
/** Device ids the browser currently reports for this device kind. */
|
||||
readonly availableDeviceIds: readonly string[];
|
||||
}
|
||||
|
||||
export interface AudioDeviceSelectionOutcome {
|
||||
/** The device id to use now. */
|
||||
readonly deviceId: string;
|
||||
/** True only when a real device list proved the saved choice is gone. */
|
||||
readonly didFallBack: boolean;
|
||||
}
|
||||
|
||||
export interface MicrophoneConstraintsInput {
|
||||
readonly deviceId: string;
|
||||
/** Whether the browser's own noise suppression should run (off when RNNoise handles it). */
|
||||
readonly browserNoiseSuppression: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Decide which device to use, falling back to the system default when the
|
||||
* saved choice is provably gone.
|
||||
*/
|
||||
export function resolveAudioDeviceSelection(
|
||||
input: AudioDeviceSelectionInput
|
||||
): AudioDeviceSelectionOutcome {
|
||||
const { selectedDeviceId } = input;
|
||||
|
||||
if (!selectedDeviceId) {
|
||||
return { deviceId: SYSTEM_DEFAULT_AUDIO_DEVICE_ID, didFallBack: false };
|
||||
}
|
||||
|
||||
const knownDeviceIds = input.availableDeviceIds.filter((deviceId) => !!deviceId);
|
||||
|
||||
if (knownDeviceIds.length === 0) {
|
||||
return { deviceId: selectedDeviceId, didFallBack: false };
|
||||
}
|
||||
|
||||
if (knownDeviceIds.includes(selectedDeviceId)) {
|
||||
return { deviceId: selectedDeviceId, didFallBack: false };
|
||||
}
|
||||
|
||||
return { deviceId: SYSTEM_DEFAULT_AUDIO_DEVICE_ID, didFallBack: true };
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the `getUserMedia` constraints for the microphone.
|
||||
*
|
||||
* `deviceId` is `exact`, because a bare id is only a preference the browser may
|
||||
* ignore - against real hardware it can hand back the current default, so the
|
||||
* picker appears to do nothing. Callers must handle `OverconstrainedError` by
|
||||
* retrying with the system default (see `isDeviceUnavailableError`).
|
||||
*/
|
||||
export function buildMicrophoneConstraints(
|
||||
input: MicrophoneConstraintsInput
|
||||
): MediaStreamConstraints {
|
||||
const audio: MediaTrackConstraints = {
|
||||
echoCancellation: true,
|
||||
noiseSuppression: input.browserNoiseSuppression,
|
||||
autoGainControl: true
|
||||
};
|
||||
|
||||
if (input.deviceId) {
|
||||
audio.deviceId = { exact: input.deviceId };
|
||||
}
|
||||
|
||||
return { audio, video: false };
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether a `getUserMedia` rejection means "that exact device is not usable",
|
||||
* as opposed to a denied permission or a missing API, which must keep failing.
|
||||
*/
|
||||
export function isDeviceUnavailableError(error: unknown): boolean {
|
||||
const name = (error as { name?: string } | null)?.name;
|
||||
|
||||
return name === 'OverconstrainedError' || name === 'NotFoundError';
|
||||
}
|
||||
@@ -0,0 +1,160 @@
|
||||
import {
|
||||
describe,
|
||||
expect,
|
||||
it
|
||||
} from 'vitest';
|
||||
import {
|
||||
decideVoicePathRouting,
|
||||
isSameVoiceChannel,
|
||||
type VoicePathRoutingInput
|
||||
} from './voice-path-routing.rules';
|
||||
|
||||
const OUR_CHANNEL = { roomId: 'voice-1', serverId: 'server-1' };
|
||||
|
||||
function input(overrides: Partial<VoicePathRoutingInput> = {}): VoicePathRoutingInput {
|
||||
return {
|
||||
hasLocalVoice: true,
|
||||
localVoiceChannel: OUR_CHANNEL,
|
||||
rosterPresenceAt: 1_000,
|
||||
peerReport: null,
|
||||
hasEstablishedPath: true,
|
||||
isPeerConnectionClosed: false,
|
||||
...overrides
|
||||
};
|
||||
}
|
||||
|
||||
describe('decideVoicePathRouting', () => {
|
||||
it('opens the path to a peer the roster lists in our channel', () => {
|
||||
expect(decideVoicePathRouting(input())).toBe('open');
|
||||
});
|
||||
|
||||
it('opens the path to a peer that reports our channel itself', () => {
|
||||
const decision = decideVoicePathRouting(input({
|
||||
rosterPresenceAt: null,
|
||||
peerReport: {
|
||||
at: 2_000,
|
||||
isConnected: true,
|
||||
...OUR_CHANNEL
|
||||
}
|
||||
}));
|
||||
|
||||
expect(decision).toBe('open');
|
||||
});
|
||||
|
||||
// The bug this file exists for: a dead socket makes the server broadcast
|
||||
// `user_left`, which wipes the roster copy of a peer that never left voice.
|
||||
it('holds an established path when the roster no longer knows the peer', () => {
|
||||
const decision = decideVoicePathRouting(input({
|
||||
rosterPresenceAt: null,
|
||||
peerReport: null,
|
||||
hasEstablishedPath: true
|
||||
}));
|
||||
|
||||
expect(decision).toBe('hold');
|
||||
});
|
||||
|
||||
it('never opens a new path for a peer nothing confirms', () => {
|
||||
const decision = decideVoicePathRouting(input({
|
||||
rosterPresenceAt: null,
|
||||
peerReport: null,
|
||||
hasEstablishedPath: false
|
||||
}));
|
||||
|
||||
expect(decision).toBe('close');
|
||||
});
|
||||
|
||||
it('closes the path when the peer answers that it is not in voice', () => {
|
||||
const decision = decideVoicePathRouting(input({
|
||||
rosterPresenceAt: null,
|
||||
peerReport: {
|
||||
at: 2_000,
|
||||
isConnected: false
|
||||
}
|
||||
}));
|
||||
|
||||
expect(decision).toBe('close');
|
||||
});
|
||||
|
||||
it('closes the path when the peer reports another voice channel', () => {
|
||||
const decision = decideVoicePathRouting(input({
|
||||
rosterPresenceAt: null,
|
||||
peerReport: {
|
||||
at: 2_000,
|
||||
isConnected: true,
|
||||
roomId: 'voice-2',
|
||||
serverId: 'server-1'
|
||||
}
|
||||
}));
|
||||
|
||||
expect(decision).toBe('close');
|
||||
});
|
||||
|
||||
it('lets the peer own word overrule a roster that still lists it', () => {
|
||||
const decision = decideVoicePathRouting(input({
|
||||
rosterPresenceAt: 1_000,
|
||||
peerReport: {
|
||||
at: 1_500,
|
||||
isConnected: false
|
||||
}
|
||||
}));
|
||||
|
||||
expect(decision).toBe('close');
|
||||
});
|
||||
|
||||
it('ignores a departure report the roster already superseded', () => {
|
||||
const decision = decideVoicePathRouting(input({
|
||||
rosterPresenceAt: 2_000,
|
||||
peerReport: {
|
||||
at: 1_500,
|
||||
isConnected: false
|
||||
}
|
||||
}));
|
||||
|
||||
expect(decision).toBe('open');
|
||||
});
|
||||
|
||||
it('closes every path once we leave voice ourselves', () => {
|
||||
expect(decideVoicePathRouting(input({ hasLocalVoice: false }))).toBe('close');
|
||||
});
|
||||
|
||||
// Voice starts before the channel is recorded, so an unknown local channel is not
|
||||
// a reason to cut anything - it just cannot contradict the peer.
|
||||
it('falls back to the roster while our own channel is still unknown', () => {
|
||||
expect(decideVoicePathRouting(input({ localVoiceChannel: null }))).toBe('open');
|
||||
|
||||
const unverifiableReport = decideVoicePathRouting(input({
|
||||
localVoiceChannel: null,
|
||||
rosterPresenceAt: null,
|
||||
peerReport: {
|
||||
at: 2_000,
|
||||
isConnected: true,
|
||||
roomId: 'voice-9',
|
||||
serverId: 'server-9'
|
||||
}
|
||||
}));
|
||||
|
||||
expect(unverifiableReport).toBe('hold');
|
||||
});
|
||||
|
||||
it('closes the path when the peer connection can no longer carry media', () => {
|
||||
const decision = decideVoicePathRouting(input({
|
||||
isPeerConnectionClosed: true,
|
||||
peerReport: {
|
||||
at: 2_000,
|
||||
isConnected: true,
|
||||
...OUR_CHANNEL
|
||||
}
|
||||
}));
|
||||
|
||||
expect(decision).toBe('close');
|
||||
});
|
||||
});
|
||||
|
||||
describe('isSameVoiceChannel', () => {
|
||||
it('matches only when both room and server are known and equal', () => {
|
||||
expect(isSameVoiceChannel(OUR_CHANNEL, { ...OUR_CHANNEL })).toBe(true);
|
||||
expect(isSameVoiceChannel(OUR_CHANNEL, { roomId: 'voice-2', serverId: 'server-1' })).toBe(false);
|
||||
expect(isSameVoiceChannel(OUR_CHANNEL, { roomId: 'voice-1' })).toBe(false);
|
||||
expect(isSameVoiceChannel(null, OUR_CHANNEL)).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,112 @@
|
||||
/**
|
||||
* Pure decisions about whether a voice path with one peer may stay open - both the
|
||||
* microphone we send and the audio we play back.
|
||||
*
|
||||
* Voice used to be gated purely on the observer's roster copy of the remote user's
|
||||
* voice state, which is signaling gossip. When a socket dies the signal server
|
||||
* broadcasts `user_left`, the roster copy is wiped, and the observer then cut the
|
||||
* media path to a peer that never left the channel - a silent member with no way
|
||||
* back through the UI.
|
||||
*
|
||||
* Missing gossip about a peer is not evidence the peer left voice, so it must never
|
||||
* close a negotiated path. The reverse is not symmetric: a path we never opened stays
|
||||
* closed until something positively confirms the peer is in our channel, so a guess
|
||||
* can never start sending our microphone to anyone.
|
||||
*
|
||||
* The peer's own voice report (data channel `voice-state`) mirrors its local voice
|
||||
* truth, so it outranks the roster when the two disagree and the report is not older
|
||||
* than the roster claim.
|
||||
*/
|
||||
|
||||
/** The voice channel a client is in: a channel of a server, or a direct call. */
|
||||
export interface VoiceChannelRef {
|
||||
readonly roomId?: string;
|
||||
readonly serverId?: string;
|
||||
}
|
||||
|
||||
/** What a peer last told us about its own voice membership. */
|
||||
export interface PeerVoiceReport extends VoiceChannelRef {
|
||||
/** When the peer told us, in `Date.now()` milliseconds. */
|
||||
readonly at: number;
|
||||
readonly isConnected: boolean;
|
||||
}
|
||||
|
||||
export interface VoicePathRoutingInput {
|
||||
/** We are in voice ourselves. */
|
||||
readonly hasLocalVoice: boolean;
|
||||
/** The voice channel we are in, from our own state - never from gossip. */
|
||||
readonly localVoiceChannel: VoiceChannelRef | null;
|
||||
/** When the roster last started listing this peer in our channel, or `null` when it does not. */
|
||||
readonly rosterPresenceAt: number | null;
|
||||
/** The peer's own last voice report, or `null` when it never sent one. */
|
||||
readonly peerReport: PeerVoiceReport | null;
|
||||
/** A negotiated media path with this peer already exists. */
|
||||
readonly hasEstablishedPath: boolean;
|
||||
/** The peer connection can no longer carry media at all. */
|
||||
readonly isPeerConnectionClosed: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* - `open`: the peer is confirmed in our channel; carry voice.
|
||||
* - `hold`: nothing confirms or denies, so an established path stays up.
|
||||
* - `close`: positive evidence that this path must not carry voice.
|
||||
*/
|
||||
export type VoicePathRouting = 'open' | 'hold' | 'close';
|
||||
|
||||
/** Whether two channel references point at the same voice channel. */
|
||||
export function isSameVoiceChannel(left: VoiceChannelRef | null, right: VoiceChannelRef | null): boolean {
|
||||
if (!left?.roomId || !left.serverId || !right?.roomId || !right.serverId) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return left.roomId === right.roomId && left.serverId === right.serverId;
|
||||
}
|
||||
|
||||
/** Decide whether a voice path with this peer may carry audio right now. */
|
||||
export function decideVoicePathRouting(input: VoicePathRoutingInput): VoicePathRouting {
|
||||
const {
|
||||
hasLocalVoice,
|
||||
localVoiceChannel,
|
||||
rosterPresenceAt,
|
||||
peerReport,
|
||||
hasEstablishedPath,
|
||||
isPeerConnectionClosed
|
||||
} = input;
|
||||
|
||||
// We left voice, or the transport is gone: both are positive evidence.
|
||||
if (!hasLocalVoice) {
|
||||
return 'close';
|
||||
}
|
||||
|
||||
if (isPeerConnectionClosed) {
|
||||
return 'close';
|
||||
}
|
||||
|
||||
// Our own channel can still be unset while voice starts, and an unknown channel
|
||||
// cannot contradict anything the peer says about the channel it is in.
|
||||
const knowsLocalChannel = !!localVoiceChannel?.roomId && !!localVoiceChannel.serverId;
|
||||
const reportsOurChannel = !!peerReport
|
||||
&& peerReport.isConnected
|
||||
&& knowsLocalChannel
|
||||
&& isSameVoiceChannel(peerReport, localVoiceChannel);
|
||||
|
||||
if (reportsOurChannel) {
|
||||
return 'open';
|
||||
}
|
||||
|
||||
const reportsAway = !!peerReport
|
||||
&& (!peerReport.isConnected || (knowsLocalChannel && !isSameVoiceChannel(peerReport, localVoiceChannel)));
|
||||
const rosterOutranksReport = rosterPresenceAt !== null
|
||||
&& (!reportsAway || peerReport.at < rosterPresenceAt);
|
||||
|
||||
if (rosterOutranksReport) {
|
||||
return 'open';
|
||||
}
|
||||
|
||||
if (reportsAway) {
|
||||
return 'close';
|
||||
}
|
||||
|
||||
// Nothing confirms and nothing denies. Only an already-negotiated path survives that.
|
||||
return hasEstablishedPath ? 'hold' : 'close';
|
||||
}
|
||||
+10
-21
@@ -1,4 +1,4 @@
|
||||
/* eslint-disable @typescript-eslint/member-ordering, @typescript-eslint/no-unused-vars */
|
||||
/* eslint-disable @typescript-eslint/no-unused-vars */
|
||||
import {
|
||||
Component,
|
||||
inject,
|
||||
@@ -20,6 +20,7 @@ import {
|
||||
} from '@ng-icons/lucide';
|
||||
|
||||
import { VoiceSessionFacade } from '../../application/facades/voice-session.facade';
|
||||
import { VoiceAudioDeviceService } from '../../application/services/voice-audio-device.service';
|
||||
import { loadVoiceSettingsFromStorage, saveVoiceSettingsToStorage } from '../../infrastructure/util/voice-settings-storage.util';
|
||||
import { VoiceConnectionFacade } from '../../../../domains/voice-connection';
|
||||
import { VoicePlaybackService } from '../../../../domains/voice-connection';
|
||||
@@ -70,6 +71,7 @@ export class FloatingVoiceControlsComponent implements OnInit {
|
||||
readonly showScreenShareButton = computed(() => !this.viewport.isMobile() && !this.mobilePlatform.isNativeMobile());
|
||||
private readonly voiceSessionService = inject(VoiceSessionFacade);
|
||||
private readonly voicePlayback = inject(VoicePlaybackService);
|
||||
private readonly audioDevices = inject(VoiceAudioDeviceService);
|
||||
private readonly store = inject(Store);
|
||||
private readonly appI18n = inject(AppI18nService);
|
||||
|
||||
@@ -80,29 +82,23 @@ export class FloatingVoiceControlsComponent implements OnInit {
|
||||
voiceSession = this.voiceSessionService.voiceSession;
|
||||
|
||||
isConnected = computed(() => this.webrtcService.isVoiceConnected());
|
||||
isMuted = signal(false);
|
||||
isDeafened = signal(false);
|
||||
/** Same media-layer truth the in-channel controls read, so the two never disagree. */
|
||||
isMuted = computed(() => this.webrtcService.isMuted());
|
||||
isDeafened = computed(() => this.webrtcService.isDeafened());
|
||||
isScreenSharing = this.screenShareService.isScreenSharing;
|
||||
includeSystemAudio = signal(false);
|
||||
screenShareQuality = signal<ScreenShareQuality>('balanced');
|
||||
askScreenShareQuality = signal(true);
|
||||
showScreenShareQualityDialog = signal(false);
|
||||
|
||||
/** Sync local mute/deafen state from the WebRTC service on init. */
|
||||
ngOnInit(): void {
|
||||
// Sync mute/deafen state from webrtc service
|
||||
this.isMuted.set(this.webrtcService.isMuted());
|
||||
this.isDeafened.set(this.webrtcService.isDeafened());
|
||||
this.syncScreenShareSettings();
|
||||
|
||||
const settings = loadVoiceSettingsFromStorage();
|
||||
|
||||
this.voicePlayback.updateOutputVolume(settings.outputVolume / 100);
|
||||
this.voicePlayback.updateDeafened(this.isDeafened());
|
||||
|
||||
if (settings.outputDevice) {
|
||||
this.voicePlayback.applyOutputDevice(settings.outputDevice);
|
||||
}
|
||||
this.audioDevices.applySelectedOutputDevice();
|
||||
}
|
||||
|
||||
backToServerTitle(): string {
|
||||
@@ -118,8 +114,7 @@ export class FloatingVoiceControlsComponent implements OnInit {
|
||||
|
||||
/** Toggle microphone mute and broadcast the updated voice state. */
|
||||
toggleMute(): void {
|
||||
this.isMuted.update((current) => !current);
|
||||
this.webrtcService.toggleMute(this.isMuted());
|
||||
this.webrtcService.toggleMute(!this.isMuted());
|
||||
|
||||
// Broadcast mute state change
|
||||
this.webrtcService.broadcastMessage({
|
||||
@@ -136,13 +131,11 @@ export class FloatingVoiceControlsComponent implements OnInit {
|
||||
|
||||
/** Toggle deafen state (muting audio output) and broadcast the updated voice state. */
|
||||
toggleDeafen(): void {
|
||||
this.isDeafened.update((current) => !current);
|
||||
this.webrtcService.toggleDeafen(this.isDeafened());
|
||||
this.webrtcService.toggleDeafen(!this.isDeafened());
|
||||
this.voicePlayback.updateDeafened(this.isDeafened());
|
||||
|
||||
// When deafening, also mute
|
||||
if (this.isDeafened() && !this.isMuted()) {
|
||||
this.isMuted.set(true);
|
||||
this.webrtcService.toggleMute(true);
|
||||
}
|
||||
|
||||
@@ -211,7 +204,7 @@ export class FloatingVoiceControlsComponent implements OnInit {
|
||||
// Disable voice
|
||||
this.webrtcService.disableVoice();
|
||||
this.voicePlayback.teardownAll();
|
||||
this.voicePlayback.updateDeafened(false);
|
||||
this.voicePlayback.updateDeafened(this.isDeafened());
|
||||
|
||||
// Update user voice state in store
|
||||
const user = this.currentUser();
|
||||
@@ -229,10 +222,6 @@ export class FloatingVoiceControlsComponent implements OnInit {
|
||||
|
||||
// End voice session
|
||||
this.voiceSessionService.endSession();
|
||||
|
||||
// Reset local state
|
||||
this.isMuted.set(false);
|
||||
this.isDeafened.set(false);
|
||||
}
|
||||
|
||||
/** Return the CSS classes for the compact control button based on active state. */
|
||||
|
||||
+18
-84
@@ -1,4 +1,4 @@
|
||||
/* eslint-disable @typescript-eslint/member-ordering, @typescript-eslint/no-unused-vars, complexity */
|
||||
/* eslint-disable @typescript-eslint/no-unused-vars, complexity */
|
||||
import {
|
||||
Component,
|
||||
ElementRef,
|
||||
@@ -24,6 +24,8 @@ import {
|
||||
} from '@ng-icons/lucide';
|
||||
|
||||
import { VoiceSessionFacade } from '../../application/facades/voice-session.facade';
|
||||
import { VoiceAudioDeviceService } from '../../application/services/voice-audio-device.service';
|
||||
import { buildMicrophoneConstraints } from '../../domain/logic/audio-device-selection.rules';
|
||||
import { loadVoiceSettingsFromStorage, saveVoiceSettingsToStorage } from '../../infrastructure/util/voice-settings-storage.util';
|
||||
import { VoiceActivityService, VoiceConnectionFacade } from '../../../../domains/voice-connection';
|
||||
import { PlaybackOptions, VoicePlaybackService } from '../../../../domains/voice-connection';
|
||||
@@ -43,11 +45,6 @@ import {
|
||||
} from '../../../../shared';
|
||||
import { APP_TRANSLATE_IMPORTS, AppI18nService } from '../../../../core/i18n';
|
||||
|
||||
interface AudioDevice {
|
||||
deviceId: string;
|
||||
label: string;
|
||||
}
|
||||
|
||||
@Component({
|
||||
selector: 'app-voice-controls',
|
||||
standalone: true,
|
||||
@@ -79,6 +76,7 @@ export class VoiceControlsComponent implements OnInit, OnDestroy {
|
||||
private readonly webrtcService = inject(VoiceConnectionFacade);
|
||||
private readonly screenShareService = inject(ScreenShareFacade);
|
||||
private readonly voiceSessionService = inject(VoiceSessionFacade);
|
||||
private readonly audioDevices = inject(VoiceAudioDeviceService);
|
||||
private readonly voiceActivity = inject(VoiceActivityService);
|
||||
private readonly voicePlayback = inject(VoicePlaybackService);
|
||||
private readonly store = inject(Store);
|
||||
@@ -104,8 +102,9 @@ export class VoiceControlsComponent implements OnInit, OnDestroy {
|
||||
|
||||
return this.appI18n.instant(message);
|
||||
});
|
||||
isMuted = signal(false);
|
||||
isDeafened = signal(false);
|
||||
/** Mute and deafen are owned by the media layer; every surface reads the same truth. */
|
||||
isMuted = computed(() => this.webrtcService.isMuted());
|
||||
isDeafened = computed(() => this.webrtcService.isDeafened());
|
||||
isCameraEnabled = computed(() => this.webrtcService.isCameraEnabled());
|
||||
isScreenSharing = this.screenShareService.isScreenSharing;
|
||||
showSettings = signal(false);
|
||||
@@ -123,10 +122,6 @@ export class VoiceControlsComponent implements OnInit, OnDestroy {
|
||||
this.profileCard.open(this.hostEl.nativeElement, user, { placement: 'above', editable: true });
|
||||
}
|
||||
|
||||
inputDevices = signal<AudioDevice[]>([]);
|
||||
outputDevices = signal<AudioDevice[]>([]);
|
||||
selectedInputDevice = signal<string>('');
|
||||
selectedOutputDevice = signal<string>('');
|
||||
inputVolume = signal(100);
|
||||
outputVolume = signal(100);
|
||||
audioBitrate = signal(96);
|
||||
@@ -145,7 +140,7 @@ export class VoiceControlsComponent implements OnInit, OnDestroy {
|
||||
};
|
||||
}
|
||||
async ngOnInit(): Promise<void> {
|
||||
await this.loadAudioDevices();
|
||||
await this.audioDevices.refreshDevices();
|
||||
|
||||
// Load persisted voice settings and apply
|
||||
this.loadSettings();
|
||||
@@ -158,24 +153,6 @@ export class VoiceControlsComponent implements OnInit, OnDestroy {
|
||||
}
|
||||
}
|
||||
|
||||
async loadAudioDevices(): Promise<void> {
|
||||
try {
|
||||
if (!navigator.mediaDevices?.enumerateDevices) {
|
||||
return;
|
||||
}
|
||||
|
||||
const devices = await navigator.mediaDevices.enumerateDevices();
|
||||
|
||||
this.inputDevices.set(
|
||||
devices.filter((device) => device.kind === 'audioinput').map((device) => ({ deviceId: device.deviceId, label: device.label }))
|
||||
);
|
||||
|
||||
this.outputDevices.set(
|
||||
devices.filter((device) => device.kind === 'audiooutput').map((device) => ({ deviceId: device.deviceId, label: device.label }))
|
||||
);
|
||||
} catch (_error) {}
|
||||
}
|
||||
|
||||
async connect(): Promise<void> {
|
||||
try {
|
||||
// Require signaling connectivity first
|
||||
@@ -195,13 +172,12 @@ export class VoiceControlsComponent implements OnInit, OnDestroy {
|
||||
return;
|
||||
}
|
||||
|
||||
const stream = await navigator.mediaDevices.getUserMedia({
|
||||
audio: {
|
||||
deviceId: this.selectedInputDevice() || undefined,
|
||||
echoCancellation: true,
|
||||
noiseSuppression: !this.noiseReduction()
|
||||
}
|
||||
});
|
||||
const stream = await navigator.mediaDevices.getUserMedia(
|
||||
buildMicrophoneConstraints({
|
||||
deviceId: this.audioDevices.selectedInputDeviceId(),
|
||||
browserNoiseSuppression: !this.noiseReduction()
|
||||
})
|
||||
);
|
||||
|
||||
await this.webrtcService.setLocalStream(stream);
|
||||
|
||||
@@ -305,7 +281,7 @@ export class VoiceControlsComponent implements OnInit, OnDestroy {
|
||||
// Disable voice (stops audio tracks but keeps peer connections open for chat)
|
||||
this.webrtcService.disableVoice();
|
||||
this.voicePlayback.teardownAll();
|
||||
this.voicePlayback.updateDeafened(false);
|
||||
this.voicePlayback.updateDeafened(this.isDeafened());
|
||||
|
||||
const user = this.currentUser();
|
||||
|
||||
@@ -333,14 +309,10 @@ export class VoiceControlsComponent implements OnInit, OnDestroy {
|
||||
|
||||
// End voice session for floating controls
|
||||
this.voiceSessionService.endSession();
|
||||
|
||||
this.isMuted.set(false);
|
||||
this.isDeafened.set(false);
|
||||
}
|
||||
|
||||
toggleMute(): void {
|
||||
this.isMuted.update((current) => !current);
|
||||
this.webrtcService.toggleMute(this.isMuted());
|
||||
this.webrtcService.toggleMute(!this.isMuted());
|
||||
|
||||
// Update local store so the side panel reflects the mute state
|
||||
const user = this.currentUser();
|
||||
@@ -372,14 +344,11 @@ export class VoiceControlsComponent implements OnInit, OnDestroy {
|
||||
}
|
||||
|
||||
toggleDeafen(): void {
|
||||
this.isDeafened.update((current) => !current);
|
||||
this.webrtcService.toggleDeafen(this.isDeafened());
|
||||
|
||||
this.webrtcService.toggleDeafen(!this.isDeafened());
|
||||
this.voicePlayback.updateDeafened(this.isDeafened());
|
||||
|
||||
// When deafening, also mute
|
||||
if (this.isDeafened() && !this.isMuted()) {
|
||||
this.isMuted.set(true);
|
||||
this.webrtcService.toggleMute(true);
|
||||
}
|
||||
|
||||
@@ -492,28 +461,6 @@ export class VoiceControlsComponent implements OnInit, OnDestroy {
|
||||
this.showSettings.set(false);
|
||||
}
|
||||
|
||||
onInputDeviceChange(event: Event): void {
|
||||
const select = event.target as HTMLSelectElement;
|
||||
|
||||
this.selectedInputDevice.set(select.value);
|
||||
|
||||
// Reconnect with new device if connected
|
||||
if (this.isConnected()) {
|
||||
this.disconnect();
|
||||
this.connect();
|
||||
}
|
||||
|
||||
this.saveSettings();
|
||||
}
|
||||
|
||||
onOutputDeviceChange(event: Event): void {
|
||||
const select = event.target as HTMLSelectElement;
|
||||
|
||||
this.selectedOutputDevice.set(select.value);
|
||||
this.applyOutputDevice();
|
||||
this.saveSettings();
|
||||
}
|
||||
|
||||
onInputVolumeChange(event: Event): void {
|
||||
const input = event.target as HTMLInputElement;
|
||||
|
||||
@@ -567,8 +514,6 @@ export class VoiceControlsComponent implements OnInit, OnDestroy {
|
||||
private loadSettings(): void {
|
||||
const settings = loadVoiceSettingsFromStorage();
|
||||
|
||||
this.selectedInputDevice.set(settings.inputDevice);
|
||||
this.selectedOutputDevice.set(settings.outputDevice);
|
||||
this.inputVolume.set(settings.inputVolume);
|
||||
this.outputVolume.set(settings.outputVolume);
|
||||
this.audioBitrate.set(settings.audioBitrate);
|
||||
@@ -581,8 +526,6 @@ export class VoiceControlsComponent implements OnInit, OnDestroy {
|
||||
|
||||
private saveSettings(): void {
|
||||
saveVoiceSettingsToStorage({
|
||||
inputDevice: this.selectedInputDevice(),
|
||||
outputDevice: this.selectedOutputDevice(),
|
||||
inputVolume: this.inputVolume(),
|
||||
outputVolume: this.outputVolume(),
|
||||
audioBitrate: this.audioBitrate(),
|
||||
@@ -601,22 +544,13 @@ export class VoiceControlsComponent implements OnInit, OnDestroy {
|
||||
this.webrtcService.setInputVolume(this.inputVolume() / 100);
|
||||
this.webrtcService.setAudioBitrate(this.audioBitrate());
|
||||
this.webrtcService.setLatencyProfile(this.latencyProfile());
|
||||
this.applyOutputDevice();
|
||||
this.audioDevices.applySelectedOutputDevice();
|
||||
// Always sync the desired noise-reduction preference (even before
|
||||
// a mic stream exists - the flag will be honoured on connect).
|
||||
this.webrtcService.toggleNoiseReduction(this.noiseReduction());
|
||||
} catch {}
|
||||
}
|
||||
|
||||
private async applyOutputDevice(): Promise<void> {
|
||||
const deviceId = this.selectedOutputDevice();
|
||||
|
||||
if (!deviceId)
|
||||
return;
|
||||
|
||||
this.voicePlayback.applyOutputDevice(deviceId);
|
||||
}
|
||||
|
||||
private syncScreenShareSettings(): void {
|
||||
const settings = loadVoiceSettingsFromStorage();
|
||||
|
||||
|
||||
@@ -1,7 +1,11 @@
|
||||
export * from './application/facades/voice-session.facade';
|
||||
export * from './application/services/voice-audio-device.service';
|
||||
export * from './application/services/voice-client-takeover.service';
|
||||
export * from './application/services/voice-workspace.service';
|
||||
export * from './domain/logic/audio-device-selection.rules';
|
||||
export * from './domain/logic/client-voice-session.rules';
|
||||
export * from './domain/logic/stream-indicator.rules';
|
||||
export * from './domain/logic/voice-path-routing.rules';
|
||||
export * from './domain/models/voice-session.model';
|
||||
export * from './infrastructure/util/voice-settings-storage.util';
|
||||
|
||||
|
||||
+23
-1
@@ -6,8 +6,16 @@
|
||||
name="lucideMic"
|
||||
class="w-5 h-5 text-muted-foreground"
|
||||
/>
|
||||
<h4 class="text-sm font-semibold text-foreground">'settings.voice.devices.title' | translate</h4>
|
||||
<h4 class="text-sm font-semibold text-foreground">{{ 'settings.voice.devices.title' | translate }}</h4>
|
||||
</div>
|
||||
@if (deviceNotice(); as notice) {
|
||||
<p
|
||||
class="mb-3 text-xs text-destructive"
|
||||
data-testid="voice-settings-device-notice"
|
||||
>
|
||||
{{ notice | translate }}
|
||||
</p>
|
||||
}
|
||||
<div class="space-y-3">
|
||||
<div>
|
||||
<label
|
||||
@@ -18,8 +26,15 @@
|
||||
<select
|
||||
(change)="onInputDeviceChange($event)"
|
||||
id="input-device-select"
|
||||
data-testid="voice-settings-input-device"
|
||||
class="w-full px-3 py-2 bg-secondary rounded-lg border border-border text-foreground text-sm focus:outline-none focus:ring-2 focus:ring-primary"
|
||||
>
|
||||
<option
|
||||
value=""
|
||||
[selected]="!selectedInputDevice()"
|
||||
>
|
||||
{{ 'settings.voice.devices.systemDefault' | translate }}
|
||||
</option>
|
||||
@for (device of inputDevices(); track device.deviceId) {
|
||||
<option
|
||||
[value]="device.deviceId"
|
||||
@@ -39,8 +54,15 @@
|
||||
<select
|
||||
(change)="onOutputDeviceChange($event)"
|
||||
id="output-device-select"
|
||||
data-testid="voice-settings-output-device"
|
||||
class="w-full px-3 py-2 bg-secondary rounded-lg border border-border text-foreground text-sm focus:outline-none focus:ring-2 focus:ring-primary"
|
||||
>
|
||||
<option
|
||||
value=""
|
||||
[selected]="!selectedOutputDevice()"
|
||||
>
|
||||
{{ 'settings.voice.devices.systemDefault' | translate }}
|
||||
</option>
|
||||
@for (device of outputDevices(); track device.deviceId) {
|
||||
<option
|
||||
[value]="device.deviceId"
|
||||
|
||||
+18
-46
@@ -1,4 +1,4 @@
|
||||
/* eslint-disable @typescript-eslint/member-ordering */
|
||||
|
||||
import {
|
||||
Component,
|
||||
inject,
|
||||
@@ -21,17 +21,16 @@ import { ElectronBridgeService } from '../../../../core/platform/electron/electr
|
||||
import { VoiceConnectionFacade } from '../../../../domains/voice-connection';
|
||||
import { SCREEN_SHARE_QUALITY_OPTIONS, ScreenShareQuality } from '../../../../domains/screen-share';
|
||||
import { screenShareQualityI18nKey } from '../../../../shared-kernel';
|
||||
import { loadVoiceSettingsFromStorage, saveVoiceSettingsToStorage } from '../../../../domains/voice-session';
|
||||
import {
|
||||
VoiceAudioDeviceService,
|
||||
loadVoiceSettingsFromStorage,
|
||||
saveVoiceSettingsToStorage
|
||||
} from '../../../../domains/voice-session';
|
||||
import { VoicePlaybackService } from '../../../../domains/voice-connection';
|
||||
import { NotificationAudioService, AppSound } from '../../../../core/services/notification-audio.service';
|
||||
import { PlatformService } from '../../../../core/platform';
|
||||
import { APP_TRANSLATE_IMPORTS, AppI18nService } from '../../../../core/i18n';
|
||||
|
||||
interface AudioDevice {
|
||||
deviceId: string;
|
||||
label: string;
|
||||
}
|
||||
|
||||
@Component({
|
||||
selector: 'app-voice-settings',
|
||||
standalone: true,
|
||||
@@ -55,6 +54,7 @@ interface AudioDevice {
|
||||
export class VoiceSettingsComponent {
|
||||
private voiceConnection = inject(VoiceConnectionFacade);
|
||||
private voicePlayback = inject(VoicePlaybackService);
|
||||
private readonly audioDevices = inject(VoiceAudioDeviceService);
|
||||
private electronBridge = inject(ElectronBridgeService);
|
||||
private platform = inject(PlatformService);
|
||||
private readonly appI18n = inject(AppI18nService);
|
||||
@@ -68,10 +68,12 @@ export class VoiceSettingsComponent {
|
||||
}))
|
||||
);
|
||||
|
||||
inputDevices = signal<AudioDevice[]>([]);
|
||||
outputDevices = signal<AudioDevice[]>([]);
|
||||
selectedInputDevice = signal<string>('');
|
||||
selectedOutputDevice = signal<string>('');
|
||||
readonly inputDevices = this.audioDevices.inputDevices;
|
||||
readonly outputDevices = this.audioDevices.outputDevices;
|
||||
readonly selectedInputDevice = this.audioDevices.selectedInputDeviceId;
|
||||
readonly selectedOutputDevice = this.audioDevices.selectedOutputDeviceId;
|
||||
/** Set when a picked device disappeared and the system default took over. */
|
||||
readonly deviceNotice = this.audioDevices.deviceNotice;
|
||||
inputVolume = signal(100);
|
||||
outputVolume = signal(100);
|
||||
audioBitrate = signal(96);
|
||||
@@ -91,41 +93,16 @@ export class VoiceSettingsComponent {
|
||||
|
||||
constructor() {
|
||||
this.loadVoiceSettings();
|
||||
this.loadAudioDevices();
|
||||
void this.audioDevices.refreshDevices();
|
||||
|
||||
if (this.isElectron) {
|
||||
void this.loadDesktopSettings();
|
||||
}
|
||||
}
|
||||
|
||||
async loadAudioDevices(): Promise<void> {
|
||||
try {
|
||||
if (!navigator.mediaDevices?.enumerateDevices)
|
||||
return;
|
||||
|
||||
const devices = await navigator.mediaDevices.enumerateDevices();
|
||||
|
||||
this.inputDevices.set(
|
||||
devices
|
||||
.filter((device) => device.kind === 'audioinput')
|
||||
.map((device) => ({ deviceId: device.deviceId,
|
||||
label: device.label }))
|
||||
);
|
||||
|
||||
this.outputDevices.set(
|
||||
devices
|
||||
.filter((device) => device.kind === 'audiooutput')
|
||||
.map((device) => ({ deviceId: device.deviceId,
|
||||
label: device.label }))
|
||||
);
|
||||
} catch {}
|
||||
}
|
||||
|
||||
loadVoiceSettings(): void {
|
||||
const settings = loadVoiceSettingsFromStorage();
|
||||
|
||||
this.selectedInputDevice.set(settings.inputDevice);
|
||||
this.selectedOutputDevice.set(settings.outputDevice);
|
||||
this.inputVolume.set(settings.inputVolume);
|
||||
this.outputVolume.set(settings.outputVolume);
|
||||
this.audioBitrate.set(settings.audioBitrate);
|
||||
@@ -147,8 +124,6 @@ export class VoiceSettingsComponent {
|
||||
|
||||
saveVoiceSettings(): void {
|
||||
saveVoiceSettingsToStorage({
|
||||
inputDevice: this.selectedInputDevice(),
|
||||
outputDevice: this.selectedOutputDevice(),
|
||||
inputVolume: this.inputVolume(),
|
||||
outputVolume: this.outputVolume(),
|
||||
audioBitrate: this.audioBitrate(),
|
||||
@@ -160,19 +135,16 @@ export class VoiceSettingsComponent {
|
||||
});
|
||||
}
|
||||
|
||||
onInputDeviceChange(event: Event): void {
|
||||
async onInputDeviceChange(event: Event): Promise<void> {
|
||||
const select = event.target as HTMLSelectElement;
|
||||
|
||||
this.selectedInputDevice.set(select.value);
|
||||
this.saveVoiceSettings();
|
||||
await this.audioDevices.selectInputDevice(select.value);
|
||||
}
|
||||
|
||||
onOutputDeviceChange(event: Event): void {
|
||||
async onOutputDeviceChange(event: Event): Promise<void> {
|
||||
const select = event.target as HTMLSelectElement;
|
||||
|
||||
this.selectedOutputDevice.set(select.value);
|
||||
this.voiceConnection.setOutputVolume(this.outputVolume() / 100);
|
||||
this.saveVoiceSettings();
|
||||
await this.audioDevices.selectOutputDevice(select.value);
|
||||
}
|
||||
|
||||
onInputVolumeChange(event: Event): void {
|
||||
|
||||
@@ -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