/** * ClassroomRTC — WebRTC mesh manager for audio + screen sharing. * * Topology: full mesh (each peer ↔ each peer). * Audio: Opus ~30 Kbit/s per connection. * Screen: teacher → renegotiation adds video track to existing connections. * * Usage: * const rtc = new ClassroomRTC({ * sessionId, userId, * onSignal(targetId, payload) — POST /signal * onScreenStream(stream|null) — remote screen arrived / stopped * onMicActive(userId, active) — visual mic indicator * }); * await rtc.startAudio(); * await rtc.connectTo([uid1, uid2]); // new joiner sends offers * rtc.handleSignal(fromUid, payload); // called from SSE * rtc.addPeerForScreenShare(uid); // if teacher, offer screen to new joiner * rtc.removePeer(uid); * rtc.destroy(); */ class ClassroomRTC { constructor({ sessionId, userId, onSignal, onScreenStream, onMicActive, onMicLevel, vadThreshold }) { this._sid = sessionId; this._uid = userId; this._onSignal = onSignal; // fn(targetUid, payload) this._onScreen = onScreenStream; // fn(stream | null) this._onMicActive = onMicActive; // fn(uid, bool) — optional this._onMicLevel = onMicLevel; // fn(uid, level 0-100) — optional, fires every tick this._vadThreshold = vadThreshold ?? 12; this._peers = new Map(); // uid → PeerState this._localStream = null; // mic audio this._screenStream = null; // outbound screen (teacher) this._muted = false; this._vadTimers = new Map(); // uid → {ctx, timer} for VAD } /* ── Audio ───────────────────────────────────────────��───────────────── */ async startAudio() { try { this._localStream = await navigator.mediaDevices.getUserMedia({ audio: true, video: false }); this._localStream.getAudioTracks().forEach(t => { t.enabled = !this._muted; }); this._startVAD(this._uid, this._localStream); return true; } catch { return false; } } /* ── Voice Activity Detection ────────────────────────────────────────── */ _startVAD(uid, stream) { if (!this._onMicActive || !window.AudioContext) return; this._stopVAD(uid); try { const ctx = new AudioContext(); const src = ctx.createMediaStreamSource(stream); const analyser = ctx.createAnalyser(); analyser.fftSize = 256; analyser.smoothingTimeConstant = 0.4; src.connect(analyser); const buf = new Uint8Array(analyser.frequencyBinCount); let speaking = false; const THRESHOLD = this._vadThreshold; const timer = setInterval(() => { analyser.getByteFrequencyData(buf); const avg = buf.reduce((a, b) => a + b, 0) / buf.length; const level = Math.min(100, Math.round((avg / 64) * 100)); const now = avg > THRESHOLD; if (now !== speaking) { speaking = now; try { if (this._onMicActive) this._onMicActive(uid, speaking); } catch {} } try { if (this._onMicLevel) this._onMicLevel(uid, level); } catch {} }, 120); this._vadTimers.set(uid, { ctx, timer }); } catch {} } _stopVAD(uid) { const vad = this._vadTimers.get(uid); if (!vad) return; clearInterval(vad.timer); try { vad.ctx.close(); } catch {} this._vadTimers.delete(uid); } _stopAllVAD() { for (const uid of [...this._vadTimers.keys()]) this._stopVAD(uid); } stopAudio() { this._stopVAD(this._uid); if (!this._localStream) return; this._localStream.getTracks().forEach(t => t.stop()); this._localStream = null; } toggleMute() { this._muted = !this._muted; if (this._localStream) { this._localStream.getAudioTracks().forEach(t => { t.enabled = !this._muted; }); } return this._muted; } isMuted() { return this._muted; } forceMute() { this._muted = true; if (this._localStream) { this._localStream.getAudioTracks().forEach(t => { t.enabled = false; }); } } /* ── Connections ─────────────────────────────────────────────────────── */ /** New joiner calls this to send offers to all existing participants. */ async connectTo(userIds) { for (const uid of userIds) { if (uid !== this._uid) await this._sendOffer(uid); } } /** Called when a new peer joins (existing participants call this to handle * teacher screen-share renegotiation if needed). */ async addPeerForScreenShare(uid) { if (!this._screenStream) return; // New person joined while we are sharing screen — renegotiate const peer = this._getPeer(uid); if (peer) { const vt = this._screenStream.getVideoTracks()[0]; if (vt && !peer.screenSender) { peer.screenSender = peer.pc.addTrack(vt, this._screenStream); // onnegotiationneeded will fire and renegotiate automatically } } } removePeer(uid) { const peer = this._peers.get(uid); if (!peer) return; this._stopVAD(uid); if (peer.audioEl) { peer.audioEl.srcObject = null; peer.audioEl.remove(); } try { peer.pc.close(); } catch {} this._peers.delete(uid); } /** * Called after SSE reconnect: re-connect peers whose ICE connection failed. * Peers that are still connected are left alone. * @param {number[]} currentUserIds — list of user ids currently in session (excluding self) */ async recoverPeers(currentUserIds) { // Close and remove peers that left during the SSE gap for (const uid of [...this._peers.keys()]) { if (!currentUserIds.includes(uid)) this.removePeer(uid); } // Re-offer peers with failed/closed ICE for (const uid of currentUserIds) { if (uid === this._uid) continue; const peer = this._peers.get(uid); if (!peer) { // Completely new peer (joined while SSE was down) — we act as initiator await this._sendOffer(uid); } else { const state = peer.pc.iceConnectionState; if (state === 'failed' || state === 'closed' || state === 'disconnected') { this.removePeer(uid); await this._sendOffer(uid); } // else: 'connected' or 'completed' → leave intact } } } /* ── Signaling ───────────────────────────────────────────────────────── */ async handleSignal(fromUid, payload) { const { kind } = payload; try { if (kind === 'offer') await this._handleOffer(fromUid, payload); else if (kind === 'answer') await this._handleAnswer(fromUid, payload); else if (kind === 'candidate') await this._handleCandidate(fromUid, payload); } catch (e) { console.warn('[RTC] handleSignal error', kind, e); } } async _handleOffer(fromUid, payload) { const peer = this._getOrCreate(fromUid); await peer.pc.setRemoteDescription({ type: 'offer', sdp: payload.sdp }); peer.remoteSet = true; await this._flushCandidates(peer); // Add own audio track if not already present if (this._localStream) { const existing = peer.pc.getSenders().map(s => s.track); this._localStream.getAudioTracks().forEach(t => { if (!existing.includes(t)) peer.pc.addTrack(t, this._localStream); }); } const answer = await peer.pc.createAnswer(); await peer.pc.setLocalDescription(answer); this._onSignal(fromUid, { kind: 'answer', sdp: answer.sdp }); } async _handleAnswer(fromUid, payload) { const peer = this._peers.get(fromUid); if (!peer) return; await peer.pc.setRemoteDescription({ type: 'answer', sdp: payload.sdp }); peer.remoteSet = true; await this._flushCandidates(peer); } async _handleCandidate(fromUid, payload) { const peer = this._peers.get(fromUid); if (!peer) return; if (peer.remoteSet) { try { await peer.pc.addIceCandidate(payload.candidate); } catch {} } else { peer.pendingCandidates.push(payload.candidate); } } async _flushCandidates(peer) { for (const c of peer.pendingCandidates) { try { await peer.pc.addIceCandidate(c); } catch {} } peer.pendingCandidates = []; } async _sendOffer(targetUid) { const peer = this._getOrCreate(targetUid); if (this._localStream) { const existing = peer.pc.getSenders().map(s => s.track); this._localStream.getAudioTracks().forEach(t => { if (!existing.includes(t)) peer.pc.addTrack(t, this._localStream); }); } if (this._screenStream) { const vt = this._screenStream.getVideoTracks()[0]; if (vt && !peer.screenSender) { peer.screenSender = peer.pc.addTrack(vt, this._screenStream); } } const offer = await peer.pc.createOffer(); await peer.pc.setLocalDescription(offer); this._onSignal(targetUid, { kind: 'offer', sdp: offer.sdp }); } _getOrCreate(uid) { if (this._peers.has(uid)) return this._peers.get(uid); return this._createPeer(uid); } _getPeer(uid) { return this._peers.get(uid) || null; } _createPeer(uid) { const ICE = { iceServers: [ { urls: 'stun:stun.l.google.com:19302' }, { urls: 'stun:stun1.l.google.com:19302' }, ]}; const pc = new RTCPeerConnection(ICE); const peer = { pc, audioEl: null, screenSender: null, remoteSet: false, pendingCandidates: [], negotiating: false }; this._peers.set(uid, peer); pc.onicecandidate = e => { if (e.candidate) this._onSignal(uid, { kind: 'candidate', candidate: e.candidate.toJSON() }); }; /* Renegotiation: fires when tracks are added/removed (e.g. screen share) */ pc.onnegotiationneeded = async () => { if (peer.negotiating || !peer.remoteSet) return; peer.negotiating = true; try { const offer = await pc.createOffer(); await pc.setLocalDescription(offer); this._onSignal(uid, { kind: 'offer', sdp: offer.sdp }); } catch {} finally { peer.negotiating = false; } }; pc.ontrack = e => { const track = e.track; const stream = e.streams[0] || new MediaStream([track]); if (track.kind === 'audio') { if (!peer.audioEl) { peer.audioEl = document.createElement('audio'); peer.audioEl.autoplay = true; peer.audioEl.style.display = 'none'; document.body.appendChild(peer.audioEl); } peer.audioEl.srcObject = stream; this._startVAD(uid, stream); } else if (track.kind === 'video') { if (this._onScreen) this._onScreen(stream); track.onended = () => { if (this._onScreen) this._onScreen(null); }; } }; return peer; } /* ── Screen sharing (teacher) ───────────────────────���─────────────────── */ async startScreenShare(constraints = {}) { try { this._screenStream = await navigator.mediaDevices.getDisplayMedia({ video: { cursor: 'always', ...constraints.video }, audio: constraints.audio ?? false, }); } catch { return null; } const vt = this._screenStream.getVideoTracks()[0]; vt.onended = () => this.stopScreenShare(); // Add to all existing peer connections (onnegotiationneeded will renegotiate) for (const [, peer] of this._peers) { if (!peer.screenSender) { peer.screenSender = peer.pc.addTrack(vt, this._screenStream); } } return this._screenStream; } async stopScreenShare() { if (!this._screenStream) return; this._screenStream.getTracks().forEach(t => t.stop()); this._screenStream = null; for (const [, peer] of this._peers) { if (peer.screenSender) { try { peer.pc.removeTrack(peer.screenSender); } catch {} peer.screenSender = null; } } } /** Take an already-acquired MediaStream (e.g. from a pre-picker) and share it. */ async useExistingScreenStream(stream) { if (this._screenStream) { this._screenStream.getTracks().forEach(t => t.stop()); this._screenStream = null; } this._screenStream = stream; const vt = this._screenStream.getVideoTracks()[0]; if (!vt) return stream; for (const [, peer] of this._peers) { if (!peer.screenSender) { peer.screenSender = peer.pc.addTrack(vt, this._screenStream); } } return stream; } isSharing() { return !!this._screenStream; } /* ── Cleanup ─────────────────────────────────────────────────────────── */ destroy() { this._stopAllVAD(); for (const uid of [...this._peers.keys()]) this.removePeer(uid); this.stopAudio(); if (this._screenStream) { this._screenStream.getTracks().forEach(t => t.stop()); this._screenStream = null; } } } if (typeof module !== 'undefined') module.exports = ClassroomRTC;