Files
zep/src/chat/services/webrtc/peer-manager.svelte.ts
T
2026-04-04 17:41:43 -04:00

419 lines
14 KiB
TypeScript

import { Identity } from "spacetimedb";
import type { Peer, WebRTCStats } from "./types";
const ICE_SERVERS: RTCConfiguration = {
iceServers: [{ urls: "stun:stun.l.google.com:19302" }],
};
export class PeerManagerService {
peers = $state<Map<string, Peer>>(new Map());
peerStatuses = $state<Map<string, string>>(new Map());
peerStats = $state<Map<string, WebRTCStats>>(new Map());
peerAudioPreferences = new Map<string, { volume: number; muted: boolean }>();
audioContext: AudioContext | null = null;
identity = $state<Identity | null>(null);
mediaType = $state<"voice" | "screen">("voice");
isDeafened = $state(false);
targetBitrate = $state<number>(5000000);
onNegotiationNeeded: (_peerIdHex: string, _pc: RTCPeerConnection) => void;
onIceCandidate: (_peerIdHex: string, _candidate: RTCIceCandidate) => void;
constructor(
identity: Identity | null,
mediaType: "voice" | "screen",
isDeafened: boolean,
onNegotiationNeeded: (_peerIdHex: string, _pc: RTCPeerConnection) => void,
onIceCandidate: (_peerIdHex: string, _candidate: RTCIceCandidate) => void,
) {
this.identity = identity;
this.mediaType = mediaType;
this.isDeafened = isDeafened;
this.onNegotiationNeeded = onNegotiationNeeded;
this.onIceCandidate = onIceCandidate;
$effect(() => {
if (this.mediaType === "voice") {
this.peers.forEach((peer, peerIdHex) => {
const pref = this.peerAudioPreferences.get(peerIdHex) || {
volume: 1,
muted: false,
};
if (peer.gainNode) {
peer.gainNode.gain.value =
this.isDeafened || pref.muted ? 0 : pref.volume;
} else if (peer.audio) {
peer.audio.muted = this.isDeafened || pref.muted;
}
});
}
});
$effect(() => {
if (this.peers.size === 0) {
if (this.peerStats.size > 0) {
this.peerStats = new Map();
}
return;
}
const interval = setInterval(async () => {
const newStats = new Map(this.peerStats);
for (const [peerIdHex, peer] of this.peers.entries()) {
try {
const stats = await peer.pc.getStats();
const prevStats = this.peerStats.get(peerIdHex);
const currentStats: WebRTCStats = {
audio: {
bytesReceived: 0,
jitter: 0,
packetsLost: 0,
bitrate: 0,
},
video: {
bytesReceived: 0,
frameWidth: 0,
frameHeight: 0,
framesPerSecond: 0,
bitrate: 0,
},
timestamp: Date.now(),
};
stats.forEach((report) => {
if (report.type === "inbound-rtp") {
const kind = report.kind;
if (kind === "audio" || kind === "video") {
const target =
kind === "audio" ? currentStats.audio : currentStats.video;
target.bytesReceived = report.bytesReceived || 0;
if (kind === "audio") {
currentStats.audio.jitter = report.jitter || 0;
currentStats.audio.packetsLost = report.packetsLost || 0;
} else {
currentStats.video.frameWidth = report.frameWidth || 0;
currentStats.video.frameHeight = report.frameHeight || 0;
currentStats.video.framesPerSecond =
report.framesPerSecond || 0;
}
if (prevStats) {
const prevTarget =
kind === "audio" ? prevStats.audio : prevStats.video;
const deltaBytes =
target.bytesReceived - prevTarget.bytesReceived;
const deltaTime =
(currentStats.timestamp - prevStats.timestamp) / 1000;
if (deltaTime > 0) {
target.bitrate = Math.max(
0,
(deltaBytes * 8) / deltaTime,
);
}
}
}
}
});
newStats.set(peerIdHex, currentStats);
} catch (e) {
console.warn(
`[WebRTC][${this.mediaType}] Failed to get stats for ${peerIdHex}`,
e,
);
}
}
this.peerStats = newStats;
}, 2000);
return () => clearInterval(interval);
});
}
getAudioContext = () => {
if (!this.audioContext) {
this.audioContext = new (
window.AudioContext || (window as any).webkitAudioContext
)();
}
if (this.audioContext.state === "suspended") {
this.audioContext.resume();
}
return this.audioContext;
};
setPeerAudioPreference = (
peerIdHex: string,
preference: { volume?: number; muted?: boolean },
) => {
const current = this.peerAudioPreferences.get(peerIdHex) || {
volume: 1,
muted: false,
};
const next = { ...current, ...preference };
this.peerAudioPreferences.set(peerIdHex, next);
const ctx = this.audioContext;
if (ctx && ctx.state === "suspended") {
ctx.resume();
}
const peer = this.peers.get(peerIdHex);
if (peer) {
if (peer.gainNode) {
peer.gainNode.gain.value =
this.isDeafened || next.muted ? 0 : next.volume;
} else if (peer.audio) {
peer.audio.volume = Math.min(1, next.volume);
peer.audio.muted = this.isDeafened || next.muted;
}
}
};
closePeer = (peerIdHex: string) => {
const peer = this.peers.get(peerIdHex);
if (peer) {
console.log(
`[WebRTC][${this.mediaType}] Closing peer connection for ${peerIdHex}`,
);
peer.pc.close();
if (peer.gainNode) {
peer.gainNode.disconnect();
}
if (peer.audio) {
peer.audio.pause();
peer.audio.srcObject = null;
}
const nextPeers = new Map(this.peers);
nextPeers.delete(peerIdHex);
this.peers = nextPeers;
const nextStatuses = new Map(this.peerStatuses);
nextStatuses.delete(peerIdHex);
this.peerStatuses = nextStatuses;
}
};
createPeerConnection = (
peerIdHex: string,
initialTracks: (MediaStreamTrack | null)[] = [],
) => {
if (this.peers.has(peerIdHex)) return this.peers.get(peerIdHex)!.pc;
if (this.identity && peerIdHex === this.identity.toHexString()) {
console.warn(
`[WebRTC][${this.mediaType}] Attempted to create a PeerConnection to self (${peerIdHex}). Ignoring.`,
);
return null as any;
}
console.log(
`[WebRTC][${this.mediaType}] Creating new PeerConnection for ${peerIdHex}`,
);
const pc = new RTCPeerConnection(ICE_SERVERS);
pc.onnegotiationneeded = () => {
console.log(
`[WebRTC][${this.mediaType}] onnegotiationneeded fired for ${peerIdHex}`,
);
this.onNegotiationNeeded(peerIdHex, pc);
};
pc.onicecandidate = (event) => {
if (event.candidate) {
this.onIceCandidate(peerIdHex, event.candidate);
}
};
pc.oniceconnectionstatechange = () => {
console.log(
`[WebRTC][${this.mediaType}] ICE state for ${peerIdHex}: ${pc.iceConnectionState}`,
);
const nextStatuses = new Map(this.peerStatuses);
nextStatuses.set(
peerIdHex,
`${pc.iceConnectionState}/${pc.signalingState}`,
);
this.peerStatuses = nextStatuses;
if (pc.iceConnectionState === "failed") {
console.log(
`[WebRTC][${this.mediaType}] ICE failed for ${peerIdHex}, closing peer for retry`,
);
this.closePeer(peerIdHex);
}
};
pc.onsignalingstatechange = () => {
console.log(
`[WebRTC][${this.mediaType}] Signaling state for ${peerIdHex}: ${pc.signalingState}`,
);
const nextStatuses = new Map(this.peerStatuses);
nextStatuses.set(
peerIdHex,
`${pc.iceConnectionState}/${pc.signalingState}`,
);
this.peerStatuses = nextStatuses;
};
pc.onconnectionstatechange = () => {
console.log(
`[WebRTC][${this.mediaType}] Connection state for ${peerIdHex}: ${pc.connectionState}`,
);
if (pc.connectionState === "connected" && this.mediaType === "screen") {
this.applyEncoderSettings(pc, peerIdHex);
}
if (pc.connectionState === "failed" || pc.connectionState === "closed") {
console.log(
`[WebRTC][${this.mediaType}] Connection ${pc.connectionState} for ${peerIdHex}, cleaning up`,
);
this.closePeer(peerIdHex);
}
};
pc.ontrack = (event) => {
console.log(
`[WebRTC][${this.mediaType}] Received track from ${peerIdHex}: ${event.track.kind} (id: ${event.track.id})`,
);
const nextPeers = new Map(this.peers);
const existingPeer = { ...(nextPeers.get(peerIdHex) || { pc }) };
if (event.track.kind === "audio") {
if (this.mediaType === "voice") {
const pref = this.peerAudioPreferences.get(peerIdHex) || {
volume: 1,
muted: false,
};
try {
const ctx = this.getAudioContext();
const stream = new MediaStream([event.track]);
if (!existingPeer.audio) {
existingPeer.audio = new Audio();
existingPeer.audio.muted = true;
}
existingPeer.audio.srcObject = stream;
existingPeer.audio.play().catch((err) => {
if (err.name !== "AbortError")
console.warn(
`[WebRTC][voice] Dummy audio play failed for ${peerIdHex}`,
err,
);
});
const source = ctx.createMediaStreamSource(stream);
const gainNode = ctx.createGain();
gainNode.gain.value =
this.isDeafened || pref.muted ? 0 : pref.volume;
source.connect(gainNode);
gainNode.connect(ctx.destination);
existingPeer.gainNode = gainNode;
console.log(
`[WebRTC][voice] Web Audio graph connected for ${peerIdHex} (volume: ${pref.volume})`,
);
} catch (e) {
console.error(
`[WebRTC][voice] Failed to setup Web Audio for ${peerIdHex}, falling back to HTMLAudioElement`,
e,
);
if (!existingPeer.audio) {
existingPeer.audio = new Audio();
existingPeer.audio.autoplay = true;
const pref = this.peerAudioPreferences.get(peerIdHex) || {
volume: 1,
muted: false,
};
existingPeer.audio.volume = Math.min(1, pref.volume);
existingPeer.audio.muted = this.isDeafened || pref.muted;
}
const stream = new MediaStream([event.track]);
existingPeer.audio.srcObject = stream;
existingPeer.audio.play().catch((err) => {
if (err.name !== "AbortError")
console.error(
`[WebRTC][voice] Error playing audio for ${peerIdHex}`,
err,
);
});
}
} else {
const currentStream = existingPeer.videoStream || new MediaStream();
if (!currentStream.getTracks().find((t) => t.id === event.track.id)) {
currentStream.addTrack(event.track);
}
existingPeer.videoStream = new MediaStream(currentStream.getTracks());
}
} else if (event.track.kind === "video") {
const currentStream = existingPeer.videoStream || new MediaStream();
if (!currentStream.getTracks().find((t) => t.id === event.track.id)) {
currentStream.addTrack(event.track);
}
existingPeer.videoStream = new MediaStream(currentStream.getTracks());
}
nextPeers.set(peerIdHex, existingPeer);
this.peers = nextPeers;
};
if (this.mediaType === "voice") {
pc.addTransceiver("audio", { direction: "sendrecv" });
} else {
pc.addTransceiver("video", { direction: "sendrecv" });
pc.addTransceiver("audio", { direction: "sendrecv" });
}
const transceivers = pc.getTransceivers();
initialTracks.forEach((track, i) => {
if (track && transceivers[i]) {
console.log(
`[WebRTC][${this.mediaType}] Attaching initial track ${i} to ${peerIdHex}`,
);
transceivers[i].sender.replaceTrack(track);
}
});
const nextPeers = new Map(this.peers);
nextPeers.set(peerIdHex, { pc });
this.peers = nextPeers;
return pc;
};
getPeer = (peerIdHex: string) => this.peers.get(peerIdHex);
applyEncoderSettings = async (pc: RTCPeerConnection, peerIdHex: string) => {
if (this.mediaType !== "screen") return;
const senders = pc.getSenders();
for (const sender of senders) {
if (sender.track?.kind === "video") {
try {
const params = sender.getParameters();
if (!params.encodings || params.encodings.length === 0) {
params.encodings = [{}];
}
// Boost bitrate for screen sharing
params.encodings[0].maxBitrate = this.targetBitrate;
params.encodings[0].priority = "high";
// Maintain resolution over framerate during congestion
// @ts-expect-error - Svelte 5 rune or non-standard property
sender.degradationPreference = "maintain-resolution";
await sender.setParameters(params);
console.log(
`[WebRTC][screen] Applied encoder settings for ${pc.remoteDescription?.type} to ${peerIdHex}: ${this.targetBitrate}bps`,
);
} catch (e) {
console.warn(`[WebRTC][screen] Failed to apply encoder settings`, e);
}
}
}
};
}