Add eslint
This commit is contained in:
@@ -1,3 +1,4 @@
|
||||
/* eslint-disable @typescript-eslint/member-ordering, @typescript-eslint/no-unused-vars, @typescript-eslint/no-explicit-any, id-length */
|
||||
/**
|
||||
* Manages local voice media: getUserMedia, mute, deafen,
|
||||
* attaching/detaching audio tracks to peer connections, bitrate tuning,
|
||||
@@ -22,7 +23,7 @@ import {
|
||||
VOICE_HEARTBEAT_INTERVAL_MS,
|
||||
DEFAULT_DISPLAY_NAME,
|
||||
P2P_TYPE_VOICE_STATE,
|
||||
LatencyProfile,
|
||||
LatencyProfile
|
||||
} from './webrtc.constants';
|
||||
|
||||
/**
|
||||
@@ -82,7 +83,7 @@ export class MediaManager {
|
||||
|
||||
constructor(
|
||||
private readonly logger: WebRTCLogger,
|
||||
private callbacks: MediaManagerCallbacks,
|
||||
private callbacks: MediaManagerCallbacks
|
||||
) {
|
||||
this.noiseReduction = new NoiseReductionManager(logger);
|
||||
}
|
||||
@@ -152,21 +153,23 @@ export class MediaManager {
|
||||
audio: {
|
||||
echoCancellation: true,
|
||||
noiseSuppression: true,
|
||||
autoGainControl: true,
|
||||
autoGainControl: true
|
||||
},
|
||||
video: false,
|
||||
video: false
|
||||
};
|
||||
|
||||
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.',
|
||||
'If accessing from an external device, use HTTPS.'
|
||||
);
|
||||
}
|
||||
|
||||
const stream = await navigator.mediaDevices.getUserMedia(mediaConstraints);
|
||||
|
||||
this.rawMicStream = stream;
|
||||
|
||||
// If the user wants noise reduction, pipe through the denoiser
|
||||
@@ -200,11 +203,13 @@ export class MediaManager {
|
||||
this.rawMicStream.getTracks().forEach((track) => track.stop());
|
||||
this.rawMicStream = null;
|
||||
}
|
||||
|
||||
this.localMediaStream = null;
|
||||
|
||||
// Remove audio senders but keep connections alive
|
||||
this.callbacks.getActivePeers().forEach((peerData) => {
|
||||
const senders = peerData.connection.getSenders();
|
||||
|
||||
senders.forEach((sender) => {
|
||||
if (sender.track?.kind === TRACK_KIND_AUDIO) {
|
||||
peerData.connection.removeTrack(sender);
|
||||
@@ -250,6 +255,7 @@ export class MediaManager {
|
||||
if (this.localMediaStream) {
|
||||
const audioTracks = this.localMediaStream.getAudioTracks();
|
||||
const newMutedState = muted !== undefined ? muted : !this.isMicMuted;
|
||||
|
||||
audioTracks.forEach((track) => {
|
||||
track.enabled = !newMutedState;
|
||||
});
|
||||
@@ -284,23 +290,27 @@ export class MediaManager {
|
||||
'Noise reduction desired =',
|
||||
shouldEnable,
|
||||
'| worklet active =',
|
||||
this.noiseReduction.isEnabled,
|
||||
this.noiseReduction.isEnabled
|
||||
);
|
||||
|
||||
if (shouldEnable === this.noiseReduction.isEnabled) return;
|
||||
if (shouldEnable === this.noiseReduction.isEnabled)
|
||||
return;
|
||||
|
||||
if (shouldEnable) {
|
||||
if (!this.rawMicStream) {
|
||||
this.logger.warn(
|
||||
'Cannot enable noise reduction — no mic stream yet (will apply on connect)',
|
||||
'Cannot enable noise reduction — no mic stream yet (will apply on connect)'
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
this.logger.info('Enabling noise reduction on raw mic stream');
|
||||
const cleanStream = await this.noiseReduction.enable(this.rawMicStream);
|
||||
|
||||
this.localMediaStream = cleanStream;
|
||||
} else {
|
||||
this.noiseReduction.disable();
|
||||
|
||||
if (this.rawMicStream) {
|
||||
this.localMediaStream = this.rawMicStream;
|
||||
}
|
||||
@@ -330,23 +340,29 @@ export class MediaManager {
|
||||
async setAudioBitrate(kbps: number): Promise<void> {
|
||||
const targetBps = Math.max(
|
||||
AUDIO_BITRATE_MIN_BPS,
|
||||
Math.min(AUDIO_BITRATE_MAX_BPS, Math.floor(kbps * KBPS_TO_BPS)),
|
||||
Math.min(AUDIO_BITRATE_MAX_BPS, Math.floor(kbps * KBPS_TO_BPS))
|
||||
);
|
||||
|
||||
this.callbacks.getActivePeers().forEach(async (peerData) => {
|
||||
const sender =
|
||||
peerData.audioSender ||
|
||||
peerData.connection.getSenders().find((s) => s.track?.kind === TRACK_KIND_AUDIO);
|
||||
if (!sender?.track) return;
|
||||
if (peerData.connection.signalingState !== 'stable') return;
|
||||
|
||||
if (!sender?.track)
|
||||
return;
|
||||
|
||||
if (peerData.connection.signalingState !== 'stable')
|
||||
return;
|
||||
|
||||
let params: RTCRtpSendParameters;
|
||||
|
||||
try {
|
||||
params = sender.getParameters();
|
||||
} catch (error) {
|
||||
this.logger.warn('getParameters failed; skipping bitrate apply', error as any);
|
||||
return;
|
||||
}
|
||||
|
||||
params.encodings = params.encodings || [{}];
|
||||
params.encodings[0].maxBitrate = targetBps;
|
||||
|
||||
@@ -380,8 +396,11 @@ export class MediaManager {
|
||||
this.stopVoiceHeartbeat();
|
||||
|
||||
// Persist voice channel context so heartbeats and state snapshots include it
|
||||
if (roomId !== undefined) this.currentVoiceRoomId = roomId;
|
||||
if (serverId !== undefined) this.currentVoiceServerId = serverId;
|
||||
if (roomId !== undefined)
|
||||
this.currentVoiceRoomId = roomId;
|
||||
|
||||
if (serverId !== undefined)
|
||||
this.currentVoiceServerId = serverId;
|
||||
|
||||
this.voicePresenceTimer = setInterval(() => {
|
||||
if (this.isVoiceActive) {
|
||||
@@ -410,7 +429,9 @@ export class MediaManager {
|
||||
*/
|
||||
private bindLocalTracksToAllPeers(): void {
|
||||
const peers = this.callbacks.getActivePeers();
|
||||
if (!this.localMediaStream) return;
|
||||
|
||||
if (!this.localMediaStream)
|
||||
return;
|
||||
|
||||
const localAudioTrack = this.localMediaStream.getAudioTracks()[0] || null;
|
||||
const localVideoTrack = this.localMediaStream.getVideoTracks()[0] || null;
|
||||
@@ -420,17 +441,20 @@ export class MediaManager {
|
||||
let audioSender =
|
||||
peerData.audioSender ||
|
||||
peerData.connection.getSenders().find((s) => s.track?.kind === TRACK_KIND_AUDIO);
|
||||
|
||||
if (!audioSender) {
|
||||
audioSender = peerData.connection.addTransceiver(TRACK_KIND_AUDIO, {
|
||||
direction: TRANSCEIVER_SEND_RECV,
|
||||
direction: TRANSCEIVER_SEND_RECV
|
||||
}).sender;
|
||||
}
|
||||
|
||||
peerData.audioSender = audioSender;
|
||||
|
||||
// Restore direction after removeTrack (which sets it to recvonly)
|
||||
const audioTransceiver = peerData.connection
|
||||
.getTransceivers()
|
||||
.find((t) => t.sender === audioSender);
|
||||
|
||||
if (
|
||||
audioTransceiver &&
|
||||
(audioTransceiver.direction === TRANSCEIVER_RECV_ONLY ||
|
||||
@@ -449,16 +473,19 @@ export class MediaManager {
|
||||
let videoSender =
|
||||
peerData.videoSender ||
|
||||
peerData.connection.getSenders().find((s) => s.track?.kind === TRACK_KIND_VIDEO);
|
||||
|
||||
if (!videoSender) {
|
||||
videoSender = peerData.connection.addTransceiver(TRACK_KIND_VIDEO, {
|
||||
direction: TRANSCEIVER_SEND_RECV,
|
||||
direction: TRANSCEIVER_SEND_RECV
|
||||
}).sender;
|
||||
}
|
||||
|
||||
peerData.videoSender = videoSender;
|
||||
|
||||
const videoTransceiver = peerData.connection
|
||||
.getTransceivers()
|
||||
.find((t) => t.sender === videoSender);
|
||||
|
||||
if (
|
||||
videoTransceiver &&
|
||||
(videoTransceiver.direction === TRANSCEIVER_RECV_ONLY ||
|
||||
@@ -481,6 +508,7 @@ export class MediaManager {
|
||||
private broadcastVoicePresence(): void {
|
||||
const oderId = this.callbacks.getIdentifyOderId();
|
||||
const displayName = this.callbacks.getIdentifyDisplayName();
|
||||
|
||||
this.callbacks.broadcastMessage({
|
||||
type: P2P_TYPE_VOICE_STATE,
|
||||
oderId,
|
||||
@@ -490,8 +518,8 @@ export class MediaManager {
|
||||
isMuted: this.isMicMuted,
|
||||
isDeafened: this.isSelfDeafened,
|
||||
roomId: this.currentVoiceRoomId,
|
||||
serverId: this.currentVoiceServerId,
|
||||
},
|
||||
serverId: this.currentVoiceServerId
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
/* eslint-disable @typescript-eslint/no-non-null-assertion */
|
||||
/**
|
||||
* Manages RNNoise-based noise reduction for microphone audio.
|
||||
*
|
||||
@@ -17,10 +18,8 @@ import { WebRTCLogger } from './webrtc-logger';
|
||||
|
||||
/** Name used to register / instantiate the AudioWorklet processor. */
|
||||
const WORKLET_PROCESSOR_NAME = 'NoiseSuppressorWorklet';
|
||||
|
||||
/** RNNoise is trained on 48 kHz audio — the AudioContext must match. */
|
||||
const RNNOISE_SAMPLE_RATE = 48_000;
|
||||
|
||||
/**
|
||||
* Relative path (from the served application root) to the **bundled**
|
||||
* worklet script placed in `public/` and served as a static asset.
|
||||
@@ -92,7 +91,9 @@ export class NoiseReductionManager {
|
||||
* used again (the caller is responsible for re-binding tracks).
|
||||
*/
|
||||
disable(): void {
|
||||
if (!this._isEnabled) return;
|
||||
if (!this._isEnabled)
|
||||
return;
|
||||
|
||||
this.teardownGraph();
|
||||
this._isEnabled = false;
|
||||
this.logger.info('Noise reduction disabled');
|
||||
@@ -108,7 +109,8 @@ export class NoiseReductionManager {
|
||||
* @returns The denoised stream, or the raw stream on failure.
|
||||
*/
|
||||
async replaceInputStream(rawStream: MediaStream): Promise<MediaStream> {
|
||||
if (!this._isEnabled) return rawStream;
|
||||
if (!this._isEnabled)
|
||||
return rawStream;
|
||||
|
||||
try {
|
||||
// Disconnect old source but keep the rest of the graph alive
|
||||
@@ -176,11 +178,13 @@ export class NoiseReductionManager {
|
||||
} catch {
|
||||
/* already disconnected */
|
||||
}
|
||||
|
||||
try {
|
||||
this.workletNode?.disconnect();
|
||||
} catch {
|
||||
/* already disconnected */
|
||||
}
|
||||
|
||||
try {
|
||||
this.destinationNode?.disconnect();
|
||||
} catch {
|
||||
@@ -197,6 +201,7 @@ export class NoiseReductionManager {
|
||||
/* best-effort */
|
||||
});
|
||||
}
|
||||
|
||||
this.audioContext = null;
|
||||
this.workletLoaded = false;
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
/* eslint-disable @typescript-eslint/member-ordering, @typescript-eslint/no-explicit-any, id-length */
|
||||
/**
|
||||
* Creates and manages RTCPeerConnections, data channels,
|
||||
* offer/answer negotiation, ICE candidates, and P2P reconnection.
|
||||
@@ -9,7 +10,7 @@ import {
|
||||
PeerData,
|
||||
DisconnectedPeerEntry,
|
||||
VoiceStateSnapshot,
|
||||
IdentifyCredentials,
|
||||
IdentifyCredentials
|
||||
} from './webrtc.types';
|
||||
import {
|
||||
ICE_SERVERS,
|
||||
@@ -37,7 +38,7 @@ import {
|
||||
SIGNALING_TYPE_OFFER,
|
||||
SIGNALING_TYPE_ANSWER,
|
||||
SIGNALING_TYPE_ICE_CANDIDATE,
|
||||
DEFAULT_DISPLAY_NAME,
|
||||
DEFAULT_DISPLAY_NAME
|
||||
} from './webrtc.constants';
|
||||
|
||||
/**
|
||||
@@ -97,7 +98,7 @@ export class PeerConnectionManager {
|
||||
|
||||
constructor(
|
||||
private readonly logger: WebRTCLogger,
|
||||
private callbacks: PeerConnectionCallbacks,
|
||||
private callbacks: PeerConnectionCallbacks
|
||||
) {}
|
||||
|
||||
/**
|
||||
@@ -125,6 +126,7 @@ export class PeerConnectionManager {
|
||||
this.logger.info('Creating peer connection', { remotePeerId, isInitiator });
|
||||
|
||||
const connection = new RTCPeerConnection({ iceServers: ICE_SERVERS });
|
||||
|
||||
let dataChannel: RTCDataChannel | null = null;
|
||||
|
||||
// ICE candidates → signaling
|
||||
@@ -132,12 +134,12 @@ export class PeerConnectionManager {
|
||||
if (event.candidate) {
|
||||
this.logger.info('ICE candidate gathered', {
|
||||
remotePeerId,
|
||||
candidateType: (event.candidate as any)?.type,
|
||||
candidateType: (event.candidate as any)?.type
|
||||
});
|
||||
this.callbacks.sendRawMessage({
|
||||
type: SIGNALING_TYPE_ICE_CANDIDATE,
|
||||
targetUserId: remotePeerId,
|
||||
payload: { candidate: event.candidate },
|
||||
payload: { candidate: event.candidate }
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -146,7 +148,7 @@ export class PeerConnectionManager {
|
||||
connection.onconnectionstatechange = () => {
|
||||
this.logger.info('connectionstatechange', {
|
||||
remotePeerId,
|
||||
state: connection.connectionState,
|
||||
state: connection.connectionState
|
||||
});
|
||||
|
||||
switch (connection.connectionState) {
|
||||
@@ -175,12 +177,14 @@ export class PeerConnectionManager {
|
||||
connection.oniceconnectionstatechange = () => {
|
||||
this.logger.info('iceconnectionstatechange', {
|
||||
remotePeerId,
|
||||
state: connection.iceConnectionState,
|
||||
state: connection.iceConnectionState
|
||||
});
|
||||
};
|
||||
|
||||
connection.onsignalingstatechange = () => {
|
||||
this.logger.info('signalingstatechange', { remotePeerId, state: connection.signalingState });
|
||||
};
|
||||
|
||||
connection.onnegotiationneeded = () => {
|
||||
this.logger.info('negotiationneeded', { remotePeerId });
|
||||
};
|
||||
@@ -199,9 +203,11 @@ export class PeerConnectionManager {
|
||||
this.logger.info('Received data channel', { remotePeerId });
|
||||
dataChannel = event.channel;
|
||||
const existing = this.activePeerConnections.get(remotePeerId);
|
||||
|
||||
if (existing) {
|
||||
existing.dataChannel = dataChannel;
|
||||
}
|
||||
|
||||
this.setupDataChannel(dataChannel, remotePeerId);
|
||||
};
|
||||
}
|
||||
@@ -212,17 +218,18 @@ export class PeerConnectionManager {
|
||||
isInitiator,
|
||||
pendingIceCandidates: [],
|
||||
audioSender: undefined,
|
||||
videoSender: undefined,
|
||||
videoSender: undefined
|
||||
};
|
||||
|
||||
// Pre-create transceivers only for the initiator (offerer).
|
||||
if (isInitiator) {
|
||||
const audioTransceiver = connection.addTransceiver(TRACK_KIND_AUDIO, {
|
||||
direction: TRANSCEIVER_SEND_RECV,
|
||||
direction: TRANSCEIVER_SEND_RECV
|
||||
});
|
||||
const videoTransceiver = connection.addTransceiver(TRACK_KIND_VIDEO, {
|
||||
direction: TRANSCEIVER_RECV_ONLY,
|
||||
direction: TRANSCEIVER_RECV_ONLY
|
||||
});
|
||||
|
||||
peerData.audioSender = audioTransceiver.sender;
|
||||
peerData.videoSender = videoTransceiver.sender;
|
||||
}
|
||||
@@ -231,6 +238,7 @@ export class PeerConnectionManager {
|
||||
|
||||
// Attach local stream to initiator
|
||||
const localStream = this.callbacks.getLocalMediaStream();
|
||||
|
||||
if (localStream && isInitiator) {
|
||||
this.logger.logStream(`localStream->${remotePeerId}`, localStream);
|
||||
localStream.getTracks().forEach((track) => {
|
||||
@@ -239,19 +247,23 @@ export class PeerConnectionManager {
|
||||
.replaceTrack(track)
|
||||
.then(() => this.logger.info('audio replaceTrack (init) ok', { remotePeerId }))
|
||||
.catch((e) =>
|
||||
this.logger.error('audio replaceTrack failed at createPeerConnection', e),
|
||||
this.logger.error('audio replaceTrack failed at createPeerConnection', e)
|
||||
);
|
||||
} else if (track.kind === TRACK_KIND_VIDEO && peerData.videoSender) {
|
||||
peerData.videoSender
|
||||
.replaceTrack(track)
|
||||
.then(() => this.logger.info('video replaceTrack (init) ok', { remotePeerId }))
|
||||
.catch((e) =>
|
||||
this.logger.error('video replaceTrack failed at createPeerConnection', e),
|
||||
this.logger.error('video replaceTrack failed at createPeerConnection', e)
|
||||
);
|
||||
} else {
|
||||
const sender = connection.addTrack(track, localStream);
|
||||
if (track.kind === TRACK_KIND_AUDIO) peerData.audioSender = sender;
|
||||
if (track.kind === TRACK_KIND_VIDEO) peerData.videoSender = sender;
|
||||
|
||||
if (track.kind === TRACK_KIND_AUDIO)
|
||||
peerData.audioSender = sender;
|
||||
|
||||
if (track.kind === TRACK_KIND_VIDEO)
|
||||
peerData.videoSender = sender;
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -277,20 +289,23 @@ export class PeerConnectionManager {
|
||||
|
||||
private async doCreateAndSendOffer(remotePeerId: string): Promise<void> {
|
||||
const peerData = this.activePeerConnections.get(remotePeerId);
|
||||
if (!peerData) return;
|
||||
|
||||
if (!peerData)
|
||||
return;
|
||||
|
||||
try {
|
||||
const offer = await peerData.connection.createOffer();
|
||||
|
||||
await peerData.connection.setLocalDescription(offer);
|
||||
this.logger.info('Sending offer', {
|
||||
remotePeerId,
|
||||
type: offer.type,
|
||||
sdpLength: offer.sdp?.length,
|
||||
sdpLength: offer.sdp?.length
|
||||
});
|
||||
this.callbacks.sendRawMessage({
|
||||
type: SIGNALING_TYPE_OFFER,
|
||||
targetUserId: remotePeerId,
|
||||
payload: { sdp: offer },
|
||||
payload: { sdp: offer }
|
||||
});
|
||||
} catch (error) {
|
||||
this.logger.error('Failed to create offer', error);
|
||||
@@ -311,6 +326,7 @@ export class PeerConnectionManager {
|
||||
private enqueueNegotiation(peerId: string, task: () => Promise<void>): void {
|
||||
const prev = this.peerNegotiationQueue.get(peerId) ?? Promise.resolve();
|
||||
const next = prev.then(task, task); // always chain, even after rejection
|
||||
|
||||
this.peerNegotiationQueue.set(peerId, next);
|
||||
}
|
||||
|
||||
@@ -336,6 +352,7 @@ export class PeerConnectionManager {
|
||||
this.logger.info('Handling offer', { fromUserId });
|
||||
|
||||
let peerData = this.activePeerConnections.get(fromUserId);
|
||||
|
||||
if (!peerData) {
|
||||
peerData = this.createPeerConnection(fromUserId, false);
|
||||
}
|
||||
@@ -359,7 +376,7 @@ export class PeerConnectionManager {
|
||||
|
||||
this.logger.info('Rolling back local offer (polite side)', { fromUserId, localId });
|
||||
await peerData.connection.setLocalDescription({
|
||||
type: 'rollback',
|
||||
type: 'rollback'
|
||||
} as RTCSessionDescriptionInit);
|
||||
}
|
||||
// ──────────────────────────────────────────────────────────────
|
||||
@@ -371,12 +388,15 @@ export class PeerConnectionManager {
|
||||
// Without this, the answerer's SDP answer defaults to recvonly for audio,
|
||||
// making the connection one-way (only the offerer's audio is heard).
|
||||
const transceivers = peerData.connection.getTransceivers();
|
||||
|
||||
for (const transceiver of transceivers) {
|
||||
const receiverKind = transceiver.receiver.track?.kind;
|
||||
|
||||
if (receiverKind === TRACK_KIND_AUDIO) {
|
||||
if (!peerData.audioSender) {
|
||||
peerData.audioSender = transceiver.sender;
|
||||
}
|
||||
|
||||
// Promote to sendrecv so the SDP answer includes a send direction,
|
||||
// enabling bidirectional audio regardless of who initiated the connection.
|
||||
transceiver.direction = TRANSCEIVER_SEND_RECV;
|
||||
@@ -387,8 +407,10 @@ export class PeerConnectionManager {
|
||||
|
||||
// Attach local tracks (answerer side)
|
||||
const localStream = this.callbacks.getLocalMediaStream();
|
||||
|
||||
if (localStream) {
|
||||
this.logger.logStream(`localStream->${fromUserId} (answerer)`, localStream);
|
||||
|
||||
for (const track of localStream.getTracks()) {
|
||||
if (track.kind === TRACK_KIND_AUDIO && peerData.audioSender) {
|
||||
await peerData.audioSender.replaceTrack(track);
|
||||
@@ -404,20 +426,22 @@ export class PeerConnectionManager {
|
||||
for (const candidate of peerData.pendingIceCandidates) {
|
||||
await peerData.connection.addIceCandidate(new RTCIceCandidate(candidate));
|
||||
}
|
||||
|
||||
peerData.pendingIceCandidates = [];
|
||||
|
||||
const answer = await peerData.connection.createAnswer();
|
||||
|
||||
await peerData.connection.setLocalDescription(answer);
|
||||
|
||||
this.logger.info('Sending answer', {
|
||||
to: fromUserId,
|
||||
type: answer.type,
|
||||
sdpLength: answer.sdp?.length,
|
||||
sdpLength: answer.sdp?.length
|
||||
});
|
||||
this.callbacks.sendRawMessage({
|
||||
type: SIGNALING_TYPE_ANSWER,
|
||||
targetUserId: fromUserId,
|
||||
payload: { sdp: answer },
|
||||
payload: { sdp: answer }
|
||||
});
|
||||
} catch (error) {
|
||||
this.logger.error('Failed to handle offer', error);
|
||||
@@ -442,6 +466,7 @@ export class PeerConnectionManager {
|
||||
private async doHandleAnswer(fromUserId: string, sdp: RTCSessionDescriptionInit): Promise<void> {
|
||||
this.logger.info('Handling answer', { fromUserId });
|
||||
const peerData = this.activePeerConnections.get(fromUserId);
|
||||
|
||||
if (!peerData) {
|
||||
this.logger.error('No peer for answer', new Error('Missing peer'), { fromUserId });
|
||||
return;
|
||||
@@ -450,13 +475,15 @@ export class PeerConnectionManager {
|
||||
try {
|
||||
if (peerData.connection.signalingState === 'have-local-offer') {
|
||||
await peerData.connection.setRemoteDescription(new RTCSessionDescription(sdp));
|
||||
|
||||
for (const candidate of peerData.pendingIceCandidates) {
|
||||
await peerData.connection.addIceCandidate(new RTCIceCandidate(candidate));
|
||||
}
|
||||
|
||||
peerData.pendingIceCandidates = [];
|
||||
} else {
|
||||
this.logger.warn('Ignoring answer – wrong signaling state', {
|
||||
state: peerData.connection.signalingState,
|
||||
state: peerData.connection.signalingState
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
@@ -481,9 +508,10 @@ export class PeerConnectionManager {
|
||||
|
||||
private async doHandleIceCandidate(
|
||||
fromUserId: string,
|
||||
candidate: RTCIceCandidateInit,
|
||||
candidate: RTCIceCandidateInit
|
||||
): Promise<void> {
|
||||
let peerData = this.activePeerConnections.get(fromUserId);
|
||||
|
||||
if (!peerData) {
|
||||
this.logger.info('Creating peer for early ICE', { fromUserId });
|
||||
peerData = this.createPeerConnection(fromUserId, false);
|
||||
@@ -518,20 +546,23 @@ export class PeerConnectionManager {
|
||||
|
||||
private async doRenegotiate(peerId: string): Promise<void> {
|
||||
const peerData = this.activePeerConnections.get(peerId);
|
||||
if (!peerData) return;
|
||||
|
||||
if (!peerData)
|
||||
return;
|
||||
|
||||
try {
|
||||
const offer = await peerData.connection.createOffer();
|
||||
|
||||
await peerData.connection.setLocalDescription(offer);
|
||||
this.logger.info('Renegotiate offer', {
|
||||
peerId,
|
||||
type: offer.type,
|
||||
sdpLength: offer.sdp?.length,
|
||||
sdpLength: offer.sdp?.length
|
||||
});
|
||||
this.callbacks.sendRawMessage({
|
||||
type: SIGNALING_TYPE_OFFER,
|
||||
targetUserId: peerId,
|
||||
payload: { sdp: offer },
|
||||
payload: { sdp: offer }
|
||||
});
|
||||
} catch (error) {
|
||||
this.logger.error('Failed to renegotiate', error);
|
||||
@@ -551,11 +582,13 @@ export class PeerConnectionManager {
|
||||
channel.onopen = () => {
|
||||
this.logger.info('Data channel open', { remotePeerId });
|
||||
this.sendCurrentStatesToChannel(channel, remotePeerId);
|
||||
|
||||
try {
|
||||
channel.send(JSON.stringify({ type: P2P_TYPE_STATE_REQUEST }));
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
|
||||
this.startPingInterval(remotePeerId);
|
||||
};
|
||||
|
||||
@@ -570,6 +603,7 @@ export class PeerConnectionManager {
|
||||
channel.onmessage = (event) => {
|
||||
try {
|
||||
const message = JSON.parse(event.data);
|
||||
|
||||
this.handlePeerMessage(remotePeerId, message);
|
||||
} catch (error) {
|
||||
this.logger.error('Failed to parse peer message', error);
|
||||
@@ -600,24 +634,30 @@ export class PeerConnectionManager {
|
||||
this.sendToPeer(peerId, { type: P2P_TYPE_PONG, ts: message.ts } as any);
|
||||
return;
|
||||
}
|
||||
|
||||
if (message.type === P2P_TYPE_PONG) {
|
||||
const sent = this.pendingPings.get(peerId);
|
||||
|
||||
if (sent && typeof message.ts === 'number' && message.ts === sent) {
|
||||
const latencyMs = Math.round(performance.now() - sent);
|
||||
|
||||
this.peerLatencies.set(peerId, latencyMs);
|
||||
this.peerLatencyChanged$.next({ peerId, latencyMs });
|
||||
}
|
||||
|
||||
this.pendingPings.delete(peerId);
|
||||
return;
|
||||
}
|
||||
|
||||
const enriched = { ...message, fromPeerId: peerId };
|
||||
|
||||
this.messageReceived$.next(enriched);
|
||||
}
|
||||
|
||||
/** Broadcast a ChatEvent to every peer with an open data channel. */
|
||||
broadcastMessage(event: ChatEvent): void {
|
||||
const data = JSON.stringify(event);
|
||||
|
||||
this.activePeerConnections.forEach((peerData, peerId) => {
|
||||
try {
|
||||
if (peerData.dataChannel?.readyState === DATA_CHANNEL_STATE_OPEN) {
|
||||
@@ -640,10 +680,12 @@ export class PeerConnectionManager {
|
||||
*/
|
||||
sendToPeer(peerId: string, event: ChatEvent): void {
|
||||
const peerData = this.activePeerConnections.get(peerId);
|
||||
|
||||
if (!peerData?.dataChannel || peerData.dataChannel.readyState !== DATA_CHANNEL_STATE_OPEN) {
|
||||
this.logger.warn('Peer not connected – cannot send', { peerId });
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
peerData.dataChannel.send(JSON.stringify(event));
|
||||
} catch (error) {
|
||||
@@ -662,6 +704,7 @@ export class PeerConnectionManager {
|
||||
*/
|
||||
async sendToPeerBuffered(peerId: string, event: ChatEvent): Promise<void> {
|
||||
const peerData = this.activePeerConnections.get(peerId);
|
||||
|
||||
if (!peerData?.dataChannel || peerData.dataChannel.readyState !== DATA_CHANNEL_STATE_OPEN) {
|
||||
this.logger.warn('Peer not connected – cannot send buffered', { peerId });
|
||||
return;
|
||||
@@ -682,6 +725,7 @@ export class PeerConnectionManager {
|
||||
resolve();
|
||||
}
|
||||
};
|
||||
|
||||
channel.addEventListener('bufferedamountlow', handler as any, { once: true } as any);
|
||||
});
|
||||
}
|
||||
@@ -709,7 +753,7 @@ export class PeerConnectionManager {
|
||||
type: P2P_TYPE_SCREEN_STATE,
|
||||
oderId,
|
||||
displayName,
|
||||
isScreenSharing: this.callbacks.isScreenSharingActive(),
|
||||
isScreenSharing: this.callbacks.isScreenSharingActive()
|
||||
} as any);
|
||||
}
|
||||
|
||||
@@ -717,10 +761,11 @@ export class PeerConnectionManager {
|
||||
if (channel.readyState !== DATA_CHANNEL_STATE_OPEN) {
|
||||
this.logger.warn('Cannot send states – channel not open', {
|
||||
remotePeerId,
|
||||
state: channel.readyState,
|
||||
state: channel.readyState
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const credentials = this.callbacks.getIdentifyCredentials();
|
||||
const oderId = credentials?.oderId || this.callbacks.getLocalPeerId();
|
||||
const displayName = credentials?.displayName || DEFAULT_DISPLAY_NAME;
|
||||
@@ -733,8 +778,8 @@ export class PeerConnectionManager {
|
||||
type: P2P_TYPE_SCREEN_STATE,
|
||||
oderId,
|
||||
displayName,
|
||||
isScreenSharing: this.callbacks.isScreenSharingActive(),
|
||||
}),
|
||||
isScreenSharing: this.callbacks.isScreenSharingActive()
|
||||
})
|
||||
);
|
||||
this.logger.info('Sent initial states to channel', { remotePeerId, voiceState });
|
||||
} catch (e) {
|
||||
@@ -754,7 +799,7 @@ export class PeerConnectionManager {
|
||||
type: P2P_TYPE_SCREEN_STATE,
|
||||
oderId,
|
||||
displayName,
|
||||
isScreenSharing: this.callbacks.isScreenSharingActive(),
|
||||
isScreenSharing: this.callbacks.isScreenSharingActive()
|
||||
} as any);
|
||||
}
|
||||
|
||||
@@ -762,13 +807,14 @@ export class PeerConnectionManager {
|
||||
const track = event.track;
|
||||
const settings =
|
||||
typeof track.getSettings === 'function' ? track.getSettings() : ({} as MediaTrackSettings);
|
||||
|
||||
this.logger.info('Remote track', {
|
||||
remotePeerId,
|
||||
kind: track.kind,
|
||||
id: track.id,
|
||||
enabled: track.enabled,
|
||||
readyState: track.readyState,
|
||||
settings,
|
||||
settings
|
||||
});
|
||||
this.logger.attachTrackDiagnostics(track, `remote:${remotePeerId}:${track.kind}`);
|
||||
|
||||
@@ -777,16 +823,17 @@ export class PeerConnectionManager {
|
||||
this.logger.info('Skipping inactive video track', {
|
||||
remotePeerId,
|
||||
enabled: track.enabled,
|
||||
readyState: track.readyState,
|
||||
readyState: track.readyState
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Merge into composite stream per peer
|
||||
let compositeStream = this.remotePeerStreams.get(remotePeerId) || new MediaStream();
|
||||
const compositeStream = this.remotePeerStreams.get(remotePeerId) || new MediaStream();
|
||||
const trackAlreadyAdded = compositeStream
|
||||
.getTracks()
|
||||
.some((existingTrack) => existingTrack.id === track.id);
|
||||
|
||||
if (!trackAlreadyAdded) {
|
||||
try {
|
||||
compositeStream.addTrack(track);
|
||||
@@ -794,6 +841,7 @@ export class PeerConnectionManager {
|
||||
this.logger.warn('Failed to add track to composite stream', e as any);
|
||||
}
|
||||
}
|
||||
|
||||
this.remotePeerStreams.set(remotePeerId, compositeStream);
|
||||
this.remoteStream$.next({ peerId: remotePeerId, stream: compositeStream });
|
||||
}
|
||||
@@ -805,8 +853,11 @@ export class PeerConnectionManager {
|
||||
*/
|
||||
removePeer(peerId: string): void {
|
||||
const peerData = this.activePeerConnections.get(peerId);
|
||||
|
||||
if (peerData) {
|
||||
if (peerData.dataChannel) peerData.dataChannel.close();
|
||||
if (peerData.dataChannel)
|
||||
peerData.dataChannel.close();
|
||||
|
||||
peerData.connection.close();
|
||||
this.activePeerConnections.delete(peerId);
|
||||
this.peerNegotiationQueue.delete(peerId);
|
||||
@@ -823,7 +874,9 @@ export class PeerConnectionManager {
|
||||
this.clearAllPeerReconnectTimers();
|
||||
this.clearAllPingTimers();
|
||||
this.activePeerConnections.forEach((peerData) => {
|
||||
if (peerData.dataChannel) peerData.dataChannel.close();
|
||||
if (peerData.dataChannel)
|
||||
peerData.dataChannel.close();
|
||||
|
||||
peerData.connection.close();
|
||||
});
|
||||
this.activePeerConnections.clear();
|
||||
@@ -836,12 +889,13 @@ export class PeerConnectionManager {
|
||||
private trackDisconnectedPeer(peerId: string): void {
|
||||
this.disconnectedPeerTracker.set(peerId, {
|
||||
lastSeenTimestamp: Date.now(),
|
||||
reconnectAttempts: 0,
|
||||
reconnectAttempts: 0
|
||||
});
|
||||
}
|
||||
|
||||
private clearPeerReconnectTimer(peerId: string): void {
|
||||
const timer = this.peerReconnectTimers.get(peerId);
|
||||
|
||||
if (timer) {
|
||||
clearInterval(timer);
|
||||
this.peerReconnectTimers.delete(peerId);
|
||||
@@ -856,11 +910,14 @@ export class PeerConnectionManager {
|
||||
}
|
||||
|
||||
private schedulePeerReconnect(peerId: string): void {
|
||||
if (this.peerReconnectTimers.has(peerId)) return;
|
||||
if (this.peerReconnectTimers.has(peerId))
|
||||
return;
|
||||
|
||||
this.logger.info('Scheduling P2P reconnect', { peerId });
|
||||
|
||||
const timer = setInterval(() => {
|
||||
const info = this.disconnectedPeerTracker.get(peerId);
|
||||
|
||||
if (!info) {
|
||||
this.clearPeerReconnectTimer(peerId);
|
||||
return;
|
||||
@@ -889,20 +946,24 @@ export class PeerConnectionManager {
|
||||
|
||||
private attemptPeerReconnect(peerId: string): void {
|
||||
const existing = this.activePeerConnections.get(peerId);
|
||||
|
||||
if (existing) {
|
||||
try {
|
||||
existing.connection.close();
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
|
||||
this.activePeerConnections.delete(peerId);
|
||||
}
|
||||
|
||||
this.createPeerConnection(peerId, true);
|
||||
this.createAndSendOffer(peerId);
|
||||
}
|
||||
|
||||
private requestVoiceStateFromPeer(peerId: string): void {
|
||||
const peerData = this.activePeerConnections.get(peerId);
|
||||
|
||||
if (peerData?.dataChannel?.readyState === DATA_CHANNEL_STATE_OPEN) {
|
||||
try {
|
||||
peerData.dataChannel.send(JSON.stringify({ type: P2P_TYPE_VOICE_STATE_REQUEST }));
|
||||
@@ -933,7 +994,7 @@ export class PeerConnectionManager {
|
||||
*/
|
||||
private removeFromConnectedPeers(peerId: string): void {
|
||||
this.connectedPeersList = this.connectedPeersList.filter(
|
||||
(connectedId) => connectedId !== peerId,
|
||||
(connectedId) => connectedId !== peerId
|
||||
);
|
||||
this.connectedPeersChanged$.next(this.connectedPeersList);
|
||||
}
|
||||
@@ -954,12 +1015,14 @@ export class PeerConnectionManager {
|
||||
// Send an immediate ping
|
||||
this.sendPing(peerId);
|
||||
const timer = setInterval(() => this.sendPing(peerId), PEER_PING_INTERVAL_MS);
|
||||
|
||||
this.peerPingTimers.set(peerId, timer);
|
||||
}
|
||||
|
||||
/** Stop the periodic ping for a specific peer. */
|
||||
private stopPingInterval(peerId: string): void {
|
||||
const timer = this.peerPingTimers.get(peerId);
|
||||
|
||||
if (timer) {
|
||||
clearInterval(timer);
|
||||
this.peerPingTimers.delete(peerId);
|
||||
@@ -975,10 +1038,14 @@ export class PeerConnectionManager {
|
||||
/** Send a single ping to a peer. */
|
||||
private sendPing(peerId: string): void {
|
||||
const peerData = this.activePeerConnections.get(peerId);
|
||||
|
||||
if (!peerData?.dataChannel || peerData.dataChannel.readyState !== DATA_CHANNEL_STATE_OPEN)
|
||||
return;
|
||||
|
||||
const ts = performance.now();
|
||||
|
||||
this.pendingPings.set(peerId, ts);
|
||||
|
||||
try {
|
||||
peerData.dataChannel.send(JSON.stringify({ type: P2P_TYPE_PING, ts }));
|
||||
} catch {
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any, @typescript-eslint/no-non-null-assertion, @typescript-eslint/member-ordering, id-length, id-denylist, max-statements-per-line, max-len */
|
||||
/**
|
||||
* Manages screen sharing: getDisplayMedia / Electron desktop capturer,
|
||||
* mixed audio (screen + mic), and attaching screen tracks to peers.
|
||||
@@ -12,7 +13,7 @@ import {
|
||||
SCREEN_SHARE_IDEAL_WIDTH,
|
||||
SCREEN_SHARE_IDEAL_HEIGHT,
|
||||
SCREEN_SHARE_IDEAL_FRAME_RATE,
|
||||
ELECTRON_ENTIRE_SCREEN_SOURCE_NAME,
|
||||
ELECTRON_ENTIRE_SCREEN_SOURCE_NAME
|
||||
} from './webrtc.constants';
|
||||
|
||||
/**
|
||||
@@ -40,7 +41,7 @@ export class ScreenShareManager {
|
||||
|
||||
constructor(
|
||||
private readonly logger: WebRTCLogger,
|
||||
private callbacks: ScreenShareCallbacks,
|
||||
private callbacks: ScreenShareCallbacks
|
||||
) {}
|
||||
|
||||
/**
|
||||
@@ -69,7 +70,7 @@ export class ScreenShareManager {
|
||||
* @returns The captured screen {@link MediaStream}.
|
||||
* @throws If both Electron and browser screen capture fail.
|
||||
*/
|
||||
async startScreenShare(includeSystemAudio: boolean = false): Promise<MediaStream> {
|
||||
async startScreenShare(includeSystemAudio = false): Promise<MediaStream> {
|
||||
try {
|
||||
this.logger.info('startScreenShare invoked', { includeSystemAudio });
|
||||
|
||||
@@ -78,19 +79,22 @@ export class ScreenShareManager {
|
||||
try {
|
||||
const sources = await (window as any).electronAPI.getSources();
|
||||
const screenSource = sources.find((s: any) => s.name === ELECTRON_ENTIRE_SCREEN_SOURCE_NAME) || sources[0];
|
||||
|
||||
const electronConstraints: any = {
|
||||
video: { mandatory: { chromeMediaSource: 'desktop', chromeMediaSourceId: screenSource.id } },
|
||||
video: { mandatory: { chromeMediaSource: 'desktop', chromeMediaSourceId: screenSource.id } }
|
||||
};
|
||||
|
||||
if (includeSystemAudio) {
|
||||
electronConstraints.audio = { mandatory: { chromeMediaSource: 'desktop', chromeMediaSourceId: screenSource.id } };
|
||||
} else {
|
||||
electronConstraints.audio = false;
|
||||
}
|
||||
|
||||
this.logger.info('desktopCapturer constraints', electronConstraints);
|
||||
|
||||
if (!navigator.mediaDevices?.getUserMedia) {
|
||||
throw new Error('navigator.mediaDevices is not available (requires HTTPS or localhost).');
|
||||
}
|
||||
|
||||
this.activeScreenStream = await navigator.mediaDevices.getUserMedia(electronConstraints);
|
||||
} catch (e) {
|
||||
this.logger.warn('Electron desktop capture failed; falling back to getDisplayMedia', e as any);
|
||||
@@ -103,14 +107,17 @@ export class ScreenShareManager {
|
||||
video: {
|
||||
width: { ideal: SCREEN_SHARE_IDEAL_WIDTH },
|
||||
height: { ideal: SCREEN_SHARE_IDEAL_HEIGHT },
|
||||
frameRate: { ideal: SCREEN_SHARE_IDEAL_FRAME_RATE },
|
||||
frameRate: { ideal: SCREEN_SHARE_IDEAL_FRAME_RATE }
|
||||
},
|
||||
audio: includeSystemAudio ? { echoCancellation: false, noiseSuppression: false, autoGainControl: false } : false,
|
||||
audio: includeSystemAudio ? { echoCancellation: false, noiseSuppression: false, autoGainControl: false } : false
|
||||
} as any;
|
||||
|
||||
this.logger.info('getDisplayMedia constraints', displayConstraints);
|
||||
|
||||
if (!navigator.mediaDevices) {
|
||||
throw new Error('navigator.mediaDevices is not available (requires HTTPS or localhost).');
|
||||
}
|
||||
|
||||
this.activeScreenStream = await (navigator.mediaDevices as any).getDisplayMedia(displayConstraints);
|
||||
}
|
||||
|
||||
@@ -126,6 +133,7 @@ export class ScreenShareManager {
|
||||
|
||||
// Auto-stop when user ends share via browser UI
|
||||
const screenVideoTrack = this.activeScreenStream!.getVideoTracks()[0];
|
||||
|
||||
if (screenVideoTrack) {
|
||||
screenVideoTrack.onended = () => {
|
||||
this.logger.warn('Screen video track ended');
|
||||
@@ -157,6 +165,7 @@ export class ScreenShareManager {
|
||||
// Clean up mixed audio
|
||||
if (this.combinedAudioStream) {
|
||||
try { this.combinedAudioStream.getTracks().forEach(track => track.stop()); } catch { /* ignore */ }
|
||||
|
||||
this.combinedAudioStream = null;
|
||||
}
|
||||
|
||||
@@ -164,26 +173,34 @@ export class ScreenShareManager {
|
||||
this.callbacks.getActivePeers().forEach((peerData, peerId) => {
|
||||
const transceivers = peerData.connection.getTransceivers();
|
||||
const videoTransceiver = transceivers.find(transceiver => transceiver.sender === peerData.videoSender || transceiver.sender === peerData.screenVideoSender);
|
||||
|
||||
if (videoTransceiver) {
|
||||
videoTransceiver.sender.replaceTrack(null).catch(() => {});
|
||||
|
||||
if (videoTransceiver.direction === TRANSCEIVER_SEND_RECV) {
|
||||
videoTransceiver.direction = TRANSCEIVER_RECV_ONLY;
|
||||
}
|
||||
}
|
||||
|
||||
peerData.screenVideoSender = undefined;
|
||||
peerData.screenAudioSender = undefined;
|
||||
|
||||
// Restore mic track
|
||||
const micTrack = this.callbacks.getLocalMediaStream()?.getAudioTracks()[0] || null;
|
||||
|
||||
if (micTrack) {
|
||||
let audioSender = peerData.audioSender || peerData.connection.getSenders().find(s => s.track?.kind === TRACK_KIND_AUDIO);
|
||||
|
||||
if (!audioSender) {
|
||||
const transceiver = peerData.connection.addTransceiver(TRACK_KIND_AUDIO, { direction: TRANSCEIVER_SEND_RECV });
|
||||
|
||||
audioSender = transceiver.sender;
|
||||
}
|
||||
|
||||
peerData.audioSender = audioSender;
|
||||
audioSender.replaceTrack(micTrack).catch((error) => this.logger.error('Restore mic replaceTrack failed', error));
|
||||
}
|
||||
|
||||
this.callbacks.renegotiate(peerId);
|
||||
});
|
||||
}
|
||||
@@ -205,15 +222,18 @@ export class ScreenShareManager {
|
||||
if (!this.audioMixingContext && (window as any).AudioContext) {
|
||||
this.audioMixingContext = new (window as any).AudioContext();
|
||||
}
|
||||
if (!this.audioMixingContext) throw new Error('AudioContext not available');
|
||||
|
||||
if (!this.audioMixingContext)
|
||||
throw new Error('AudioContext not available');
|
||||
|
||||
const destination = this.audioMixingContext.createMediaStreamDestination();
|
||||
|
||||
const screenAudioSource = this.audioMixingContext.createMediaStreamSource(new MediaStream([screenAudioTrack]));
|
||||
|
||||
screenAudioSource.connect(destination);
|
||||
|
||||
if (micAudioTrack) {
|
||||
const micAudioSource = this.audioMixingContext.createMediaStreamSource(new MediaStream([micAudioTrack]));
|
||||
|
||||
micAudioSource.connect(destination);
|
||||
this.logger.info('Mixed mic + screen audio together');
|
||||
}
|
||||
@@ -238,25 +258,33 @@ export class ScreenShareManager {
|
||||
*/
|
||||
private attachScreenTracksToPeers(includeSystemAudio: boolean): void {
|
||||
this.callbacks.getActivePeers().forEach((peerData, peerId) => {
|
||||
if (!this.activeScreenStream) return;
|
||||
if (!this.activeScreenStream)
|
||||
return;
|
||||
|
||||
const screenVideoTrack = this.activeScreenStream.getVideoTracks()[0];
|
||||
if (!screenVideoTrack) return;
|
||||
|
||||
if (!screenVideoTrack)
|
||||
return;
|
||||
|
||||
this.logger.attachTrackDiagnostics(screenVideoTrack, `screenVideo:${peerId}`);
|
||||
|
||||
// Use primary video sender/transceiver
|
||||
let videoSender = peerData.videoSender || peerData.connection.getSenders().find(s => s.track?.kind === TRACK_KIND_VIDEO);
|
||||
|
||||
if (!videoSender) {
|
||||
const videoTransceiver = peerData.connection.addTransceiver(TRACK_KIND_VIDEO, { direction: TRANSCEIVER_SEND_RECV });
|
||||
|
||||
videoSender = videoTransceiver.sender;
|
||||
peerData.videoSender = videoSender;
|
||||
} else {
|
||||
const transceivers = peerData.connection.getTransceivers();
|
||||
const videoTransceiver = transceivers.find(t => t.sender === videoSender);
|
||||
|
||||
if (videoTransceiver?.direction === TRANSCEIVER_RECV_ONLY) {
|
||||
videoTransceiver.direction = TRANSCEIVER_SEND_RECV;
|
||||
}
|
||||
}
|
||||
|
||||
peerData.screenVideoSender = videoSender;
|
||||
videoSender.replaceTrack(screenVideoTrack)
|
||||
.then(() => this.logger.info('screen video replaceTrack ok', { peerId }))
|
||||
@@ -264,15 +292,20 @@ export class ScreenShareManager {
|
||||
|
||||
// Audio handling
|
||||
const micTrack = this.callbacks.getLocalMediaStream()?.getAudioTracks()[0] || null;
|
||||
|
||||
if (includeSystemAudio) {
|
||||
const combinedTrack = this.combinedAudioStream?.getAudioTracks()[0] || null;
|
||||
|
||||
if (combinedTrack) {
|
||||
this.logger.attachTrackDiagnostics(combinedTrack, `combinedAudio:${peerId}`);
|
||||
let audioSender = peerData.audioSender || peerData.connection.getSenders().find(s => s.track?.kind === TRACK_KIND_AUDIO);
|
||||
|
||||
if (!audioSender) {
|
||||
const transceiver = peerData.connection.addTransceiver(TRACK_KIND_AUDIO, { direction: TRANSCEIVER_SEND_RECV });
|
||||
|
||||
audioSender = transceiver.sender;
|
||||
}
|
||||
|
||||
peerData.audioSender = audioSender;
|
||||
audioSender.replaceTrack(combinedTrack)
|
||||
.then(() => this.logger.info('screen audio(combined) replaceTrack ok', { peerId }))
|
||||
@@ -281,10 +314,13 @@ export class ScreenShareManager {
|
||||
} else if (micTrack) {
|
||||
this.logger.attachTrackDiagnostics(micTrack, `micAudio:${peerId}`);
|
||||
let audioSender = peerData.audioSender || peerData.connection.getSenders().find(s => s.track?.kind === TRACK_KIND_AUDIO);
|
||||
|
||||
if (!audioSender) {
|
||||
const transceiver = peerData.connection.addTransceiver(TRACK_KIND_AUDIO, { direction: TRANSCEIVER_SEND_RECV });
|
||||
|
||||
audioSender = transceiver.sender;
|
||||
}
|
||||
|
||||
peerData.audioSender = audioSender;
|
||||
audioSender.replaceTrack(micTrack)
|
||||
.then(() => this.logger.info('screen audio(mic) replaceTrack ok', { peerId }))
|
||||
@@ -298,8 +334,10 @@ export class ScreenShareManager {
|
||||
/** Clean up all resources. */
|
||||
destroy(): void {
|
||||
this.stopScreenShare();
|
||||
|
||||
if (this.audioMixingContext) {
|
||||
try { this.audioMixingContext.close(); } catch { /* ignore */ }
|
||||
|
||||
this.audioMixingContext = null;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
/* eslint-disable @typescript-eslint/member-ordering, @typescript-eslint/no-non-null-assertion, @typescript-eslint/no-explicit-any, max-statements-per-line */
|
||||
/**
|
||||
* Manages the WebSocket connection to the signaling server,
|
||||
* including automatic reconnection and heartbeats.
|
||||
@@ -13,7 +14,7 @@ import {
|
||||
STATE_HEARTBEAT_INTERVAL_MS,
|
||||
SIGNALING_TYPE_IDENTIFY,
|
||||
SIGNALING_TYPE_JOIN_SERVER,
|
||||
SIGNALING_TYPE_VIEW_SERVER,
|
||||
SIGNALING_TYPE_VIEW_SERVER
|
||||
} from './webrtc.constants';
|
||||
|
||||
export class SignalingManager {
|
||||
@@ -36,7 +37,7 @@ export class SignalingManager {
|
||||
private readonly logger: WebRTCLogger,
|
||||
private readonly getLastIdentify: () => IdentifyCredentials | null,
|
||||
private readonly getLastJoinedServer: () => JoinedServerInfo | null,
|
||||
private readonly getMemberServerIds: () => ReadonlySet<string>,
|
||||
private readonly getMemberServerIds: () => ReadonlySet<string>
|
||||
) {}
|
||||
|
||||
/** Open (or re-open) a WebSocket to the signaling server. */
|
||||
@@ -63,6 +64,7 @@ export class SignalingManager {
|
||||
this.signalingWebSocket.onmessage = (event) => {
|
||||
try {
|
||||
const message = JSON.parse(event.data);
|
||||
|
||||
this.messageReceived$.next(message);
|
||||
} catch (error) {
|
||||
this.logger.error('Failed to parse signaling message', error);
|
||||
@@ -89,18 +91,22 @@ export class SignalingManager {
|
||||
|
||||
/** Ensure signaling is connected; try reconnecting if not. */
|
||||
async ensureConnected(timeoutMs: number = SIGNALING_CONNECT_TIMEOUT_MS): Promise<boolean> {
|
||||
if (this.isSocketOpen()) return true;
|
||||
if (!this.lastSignalingUrl) return false;
|
||||
if (this.isSocketOpen())
|
||||
return true;
|
||||
|
||||
if (!this.lastSignalingUrl)
|
||||
return false;
|
||||
|
||||
return new Promise<boolean>((resolve) => {
|
||||
let settled = false;
|
||||
|
||||
const timeout = setTimeout(() => {
|
||||
if (!settled) { settled = true; resolve(false); }
|
||||
}, timeoutMs);
|
||||
|
||||
this.connect(this.lastSignalingUrl!).subscribe({
|
||||
next: () => { if (!settled) { settled = true; clearTimeout(timeout); resolve(true); } },
|
||||
error: () => { if (!settled) { settled = true; clearTimeout(timeout); resolve(false); } },
|
||||
error: () => { if (!settled) { settled = true; clearTimeout(timeout); resolve(false); } }
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -111,7 +117,9 @@ export class SignalingManager {
|
||||
this.logger.error('Signaling socket not connected', new Error('Socket not open'));
|
||||
return;
|
||||
}
|
||||
|
||||
const fullMessage: SignalingMessage = { ...message, from: localPeerId, timestamp: Date.now() };
|
||||
|
||||
this.signalingWebSocket!.send(JSON.stringify(fullMessage));
|
||||
}
|
||||
|
||||
@@ -121,6 +129,7 @@ export class SignalingManager {
|
||||
this.logger.error('Signaling socket not connected', new Error('Socket not open'));
|
||||
return;
|
||||
}
|
||||
|
||||
this.signalingWebSocket!.send(JSON.stringify(message));
|
||||
}
|
||||
|
||||
@@ -128,6 +137,7 @@ export class SignalingManager {
|
||||
close(): void {
|
||||
this.stopHeartbeat();
|
||||
this.clearReconnect();
|
||||
|
||||
if (this.signalingWebSocket) {
|
||||
this.signalingWebSocket.close();
|
||||
this.signalingWebSocket = null;
|
||||
@@ -147,21 +157,25 @@ export class SignalingManager {
|
||||
/** Re-identify and rejoin servers after a reconnect. */
|
||||
private reIdentifyAndRejoin(): void {
|
||||
const credentials = this.getLastIdentify();
|
||||
|
||||
if (credentials) {
|
||||
this.sendRawMessage({ type: SIGNALING_TYPE_IDENTIFY, oderId: credentials.oderId, displayName: credentials.displayName });
|
||||
}
|
||||
|
||||
const memberIds = this.getMemberServerIds();
|
||||
|
||||
if (memberIds.size > 0) {
|
||||
memberIds.forEach((serverId) => {
|
||||
this.sendRawMessage({ type: SIGNALING_TYPE_JOIN_SERVER, serverId });
|
||||
});
|
||||
const lastJoined = this.getLastJoinedServer();
|
||||
|
||||
if (lastJoined) {
|
||||
this.sendRawMessage({ type: SIGNALING_TYPE_VIEW_SERVER, serverId: lastJoined.serverId });
|
||||
}
|
||||
} else {
|
||||
const lastJoined = this.getLastJoinedServer();
|
||||
|
||||
if (lastJoined) {
|
||||
this.sendRawMessage({ type: SIGNALING_TYPE_JOIN_SERVER, serverId: lastJoined.serverId });
|
||||
}
|
||||
@@ -175,18 +189,21 @@ export class SignalingManager {
|
||||
* No-ops if a timer is already pending or no URL is stored.
|
||||
*/
|
||||
private scheduleReconnect(): void {
|
||||
if (this.signalingReconnectTimer || !this.lastSignalingUrl) return;
|
||||
if (this.signalingReconnectTimer || !this.lastSignalingUrl)
|
||||
return;
|
||||
|
||||
const delay = Math.min(
|
||||
SIGNALING_RECONNECT_MAX_DELAY_MS,
|
||||
SIGNALING_RECONNECT_BASE_DELAY_MS * Math.pow(2, this.signalingReconnectAttempts),
|
||||
SIGNALING_RECONNECT_BASE_DELAY_MS * Math.pow(2, this.signalingReconnectAttempts)
|
||||
);
|
||||
|
||||
this.signalingReconnectTimer = setTimeout(() => {
|
||||
this.signalingReconnectTimer = null;
|
||||
this.signalingReconnectAttempts++;
|
||||
this.logger.info('Attempting to reconnect to signaling...');
|
||||
this.connect(this.lastSignalingUrl!).subscribe({
|
||||
next: () => { this.signalingReconnectAttempts = 0; },
|
||||
error: () => { this.scheduleReconnect(); },
|
||||
error: () => { this.scheduleReconnect(); }
|
||||
});
|
||||
}, delay);
|
||||
}
|
||||
@@ -197,6 +214,7 @@ export class SignalingManager {
|
||||
clearTimeout(this.signalingReconnectTimer);
|
||||
this.signalingReconnectTimer = null;
|
||||
}
|
||||
|
||||
this.signalingReconnectAttempts = 0;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
/* eslint-disable id-length, max-statements-per-line */
|
||||
/**
|
||||
* VoiceLevelingManager — manages per-speaker automatic gain control
|
||||
* pipelines for remote voice streams.
|
||||
@@ -70,7 +71,7 @@ export const DEFAULT_VOICE_LEVELING_SETTINGS: VoiceLevelingSettings = {
|
||||
strength: 'medium',
|
||||
maxGainDb: 12,
|
||||
speed: 'medium',
|
||||
noiseGate: false,
|
||||
noiseGate: false
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -89,7 +90,6 @@ interface SpeakerPipeline {
|
||||
|
||||
/** AudioWorklet module path (served from public/). */
|
||||
const WORKLET_MODULE_PATH = 'voice-leveling-worklet.js';
|
||||
|
||||
/** Processor name — must match `registerProcessor` in the worklet. */
|
||||
const WORKLET_PROCESSOR_NAME = 'VoiceLevelingProcessor';
|
||||
|
||||
@@ -155,6 +155,7 @@ export class VoiceLevelingManager {
|
||||
async enable(peerId: string, stream: MediaStream): Promise<MediaStream> {
|
||||
// Reuse existing pipeline if it targets the same stream
|
||||
const existing = this.pipelines.get(peerId);
|
||||
|
||||
if (existing && existing.originalStream === stream) {
|
||||
return existing.destination.stream;
|
||||
}
|
||||
@@ -173,10 +174,11 @@ export class VoiceLevelingManager {
|
||||
|
||||
try {
|
||||
const pipeline = await this._buildPipeline(stream);
|
||||
|
||||
this.pipelines.set(peerId, pipeline);
|
||||
this.logger.info('VoiceLeveling: pipeline created', {
|
||||
peerId,
|
||||
fallback: pipeline.isFallback,
|
||||
fallback: pipeline.isFallback
|
||||
});
|
||||
return pipeline.destination.stream;
|
||||
} catch (err) {
|
||||
@@ -193,7 +195,10 @@ export class VoiceLevelingManager {
|
||||
*/
|
||||
disable(peerId: string): void {
|
||||
const pipeline = this.pipelines.get(peerId);
|
||||
if (!pipeline) return;
|
||||
|
||||
if (!pipeline)
|
||||
return;
|
||||
|
||||
this._disposePipeline(pipeline);
|
||||
this.pipelines.delete(peerId);
|
||||
this.logger.info('VoiceLeveling: pipeline removed', { peerId });
|
||||
@@ -207,15 +212,19 @@ export class VoiceLevelingManager {
|
||||
|
||||
setSpeakerVolume(peerId: string, volume: number): void {
|
||||
const pipeline = this.pipelines.get(peerId);
|
||||
if (!pipeline) return;
|
||||
|
||||
if (!pipeline)
|
||||
return;
|
||||
|
||||
pipeline.gainNode.gain.setValueAtTime(
|
||||
Math.max(0, Math.min(1, volume)),
|
||||
pipeline.ctx.currentTime,
|
||||
pipeline.ctx.currentTime
|
||||
);
|
||||
}
|
||||
|
||||
setMasterVolume(volume: number): void {
|
||||
const clamped = Math.max(0, Math.min(1, volume));
|
||||
|
||||
this.pipelines.forEach((pipeline) => {
|
||||
pipeline.gainNode.gain.setValueAtTime(clamped, pipeline.ctx.currentTime);
|
||||
});
|
||||
@@ -224,9 +233,11 @@ export class VoiceLevelingManager {
|
||||
/** Tear down all pipelines and release all resources. */
|
||||
destroy(): void {
|
||||
this.disableAll();
|
||||
|
||||
if (this._sharedCtx && this._sharedCtx.state !== 'closed') {
|
||||
this._sharedCtx.close().catch(() => { /* best-effort */ });
|
||||
}
|
||||
|
||||
this._sharedCtx = null;
|
||||
this._workletLoaded = false;
|
||||
this._workletAvailable = null;
|
||||
@@ -243,9 +254,9 @@ export class VoiceLevelingManager {
|
||||
|
||||
const source = ctx.createMediaStreamSource(stream);
|
||||
const gainNode = ctx.createGain();
|
||||
|
||||
gainNode.gain.value = 1.0;
|
||||
const destination = ctx.createMediaStreamDestination();
|
||||
|
||||
const workletOk = await this._ensureWorkletLoaded(ctx);
|
||||
|
||||
if (workletOk) {
|
||||
@@ -263,7 +274,7 @@ export class VoiceLevelingManager {
|
||||
gainNode,
|
||||
destination,
|
||||
originalStream: stream,
|
||||
isFallback: false,
|
||||
isFallback: false
|
||||
};
|
||||
|
||||
this._pushSettingsToPipeline(pipeline);
|
||||
@@ -284,7 +295,7 @@ export class VoiceLevelingManager {
|
||||
gainNode,
|
||||
destination,
|
||||
originalStream: stream,
|
||||
isFallback: true,
|
||||
isFallback: true
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -300,14 +311,18 @@ export class VoiceLevelingManager {
|
||||
if (this._sharedCtx && this._sharedCtx.state !== 'closed') {
|
||||
return this._sharedCtx;
|
||||
}
|
||||
|
||||
this._sharedCtx = new AudioContext();
|
||||
this._workletLoaded = false;
|
||||
return this._sharedCtx;
|
||||
}
|
||||
|
||||
private async _ensureWorkletLoaded(ctx: AudioContext): Promise<boolean> {
|
||||
if (this._workletAvailable === false) return false;
|
||||
if (this._workletLoaded && this._workletAvailable === true) return true;
|
||||
if (this._workletAvailable === false)
|
||||
return false;
|
||||
|
||||
if (this._workletLoaded && this._workletAvailable === true)
|
||||
return true;
|
||||
|
||||
try {
|
||||
await ctx.audioWorklet.addModule(WORKLET_MODULE_PATH);
|
||||
@@ -324,6 +339,7 @@ export class VoiceLevelingManager {
|
||||
|
||||
private _createFallbackCompressor(ctx: AudioContext): DynamicsCompressorNode {
|
||||
const compressor = ctx.createDynamicsCompressor();
|
||||
|
||||
compressor.threshold.setValueAtTime(-24, ctx.currentTime);
|
||||
compressor.knee.setValueAtTime(30, ctx.currentTime);
|
||||
compressor.ratio.setValueAtTime(3, ctx.currentTime);
|
||||
@@ -342,7 +358,7 @@ export class VoiceLevelingManager {
|
||||
maxGainDb: this._settings.maxGainDb,
|
||||
strength: this._settings.strength,
|
||||
speed: this._settings.speed,
|
||||
noiseGate: this._settings.noiseGate,
|
||||
noiseGate: this._settings.noiseGate
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -351,9 +367,13 @@ export class VoiceLevelingManager {
|
||||
|
||||
private _disposePipeline(pipeline: SpeakerPipeline): void {
|
||||
try { pipeline.source.disconnect(); } catch { /* already disconnected */ }
|
||||
|
||||
try { pipeline.workletNode?.disconnect(); } catch { /* ok */ }
|
||||
|
||||
try { pipeline.compressorNode?.disconnect(); } catch { /* ok */ }
|
||||
|
||||
try { pipeline.gainNode.disconnect(); } catch { /* ok */ }
|
||||
|
||||
try { pipeline.destination.disconnect(); } catch { /* ok */ }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,19 +1,24 @@
|
||||
/* eslint-disable max-statements-per-line */
|
||||
/**
|
||||
* Lightweight logging utility for the WebRTC subsystem.
|
||||
* All log lines are prefixed with `[WebRTC]`.
|
||||
*/
|
||||
export class WebRTCLogger {
|
||||
constructor(private readonly isEnabled: boolean = true) {}
|
||||
constructor(private readonly isEnabled = true) {}
|
||||
|
||||
/** Informational log (only when debug is enabled). */
|
||||
info(prefix: string, ...args: unknown[]): void {
|
||||
if (!this.isEnabled) return;
|
||||
if (!this.isEnabled)
|
||||
return;
|
||||
|
||||
try { console.log(`[WebRTC] ${prefix}`, ...args); } catch { /* swallow */ }
|
||||
}
|
||||
|
||||
/** Warning log (only when debug is enabled). */
|
||||
warn(prefix: string, ...args: unknown[]): void {
|
||||
if (!this.isEnabled) return;
|
||||
if (!this.isEnabled)
|
||||
return;
|
||||
|
||||
try { console.warn(`[WebRTC] ${prefix}`, ...args); } catch { /* swallow */ }
|
||||
}
|
||||
|
||||
@@ -23,20 +28,22 @@ export class WebRTCLogger {
|
||||
name: (err as any)?.name,
|
||||
message: (err as any)?.message,
|
||||
stack: (err as any)?.stack,
|
||||
...extra,
|
||||
...extra
|
||||
};
|
||||
|
||||
try { console.error(`[WebRTC] ${prefix}`, payload); } catch { /* swallow */ }
|
||||
}
|
||||
|
||||
/** Attach lifecycle event listeners to a track for debugging. */
|
||||
attachTrackDiagnostics(track: MediaStreamTrack, label: string): void {
|
||||
const settings = typeof track.getSettings === 'function' ? track.getSettings() : {} as MediaTrackSettings;
|
||||
|
||||
this.info(`Track attached: ${label}`, {
|
||||
id: track.id,
|
||||
kind: track.kind,
|
||||
readyState: track.readyState,
|
||||
contentHint: track.contentHint,
|
||||
settings,
|
||||
settings
|
||||
});
|
||||
|
||||
track.addEventListener('ended', () => this.warn(`Track ended: ${label}`, { id: track.id, kind: track.kind }));
|
||||
@@ -50,13 +57,15 @@ export class WebRTCLogger {
|
||||
this.warn(`Stream missing: ${label}`);
|
||||
return;
|
||||
}
|
||||
|
||||
const audioTracks = stream.getAudioTracks();
|
||||
const videoTracks = stream.getVideoTracks();
|
||||
|
||||
this.info(`Stream ready: ${label}`, {
|
||||
id: (stream as any).id,
|
||||
audioTrackCount: audioTracks.length,
|
||||
videoTrackCount: videoTracks.length,
|
||||
allTrackIds: stream.getTracks().map(streamTrack => ({ id: streamTrack.id, kind: streamTrack.kind })),
|
||||
allTrackIds: stream.getTracks().map(streamTrack => ({ id: streamTrack.id, kind: streamTrack.kind }))
|
||||
});
|
||||
audioTracks.forEach((audioTrack, index) => this.attachTrackDiagnostics(audioTrack, `${label}:audio#${index}`));
|
||||
videoTracks.forEach((videoTrack, index) => this.attachTrackDiagnostics(videoTrack, `${label}:video#${index}`));
|
||||
|
||||
@@ -8,7 +8,7 @@ export const ICE_SERVERS: RTCIceServer[] = [
|
||||
{ urls: 'stun:stun1.l.google.com:19302' },
|
||||
{ urls: 'stun:stun2.l.google.com:19302' },
|
||||
{ urls: 'stun:stun3.l.google.com:19302' },
|
||||
{ urls: 'stun:stun4.l.google.com:19302' },
|
||||
{ urls: 'stun:stun4.l.google.com:19302' }
|
||||
];
|
||||
|
||||
/** Base delay (ms) for exponential backoff on signaling reconnect */
|
||||
@@ -51,7 +51,7 @@ export const KBPS_TO_BPS = 1_000;
|
||||
export const LATENCY_PROFILE_BITRATES = {
|
||||
low: 64_000,
|
||||
balanced: 96_000,
|
||||
high: 128_000,
|
||||
high: 128_000
|
||||
} as const;
|
||||
export type LatencyProfile = keyof typeof LATENCY_PROFILE_BITRATES;
|
||||
|
||||
|
||||
Reference in New Issue
Block a user