Add eslint

This commit is contained in:
2026-03-03 22:56:12 +01:00
parent d641229f9d
commit ad0e28bf84
92 changed files with 2656 additions and 1127 deletions

View File

@@ -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
}
});
}

View File

@@ -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;
}

View File

@@ -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 {

View File

@@ -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;
}
}

View File

@@ -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;
}

View File

@@ -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 */ }
}
}

View File

@@ -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}`));

View File

@@ -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;