/** * Browser-side WebRTC client — manages audio capture, playback, and peer connection. */ export interface IAudioDevices { inputs: MediaDeviceInfo[]; outputs: MediaDeviceInfo[]; } export class WebRtcClient { private pc: RTCPeerConnection | null = null; private localStream: MediaStream | null = null; private remoteAudio: HTMLAudioElement | null = null; private ws: WebSocket | null = null; private sessionId: string; private onStateChange: (state: string) => void; // Audio analysis for level meters. private localAnalyser: AnalyserNode | null = null; private remoteAnalyser: AnalyserNode | null = null; private audioCtx: AudioContext | null = null; // Device selection. private selectedInputId: string = ''; private selectedOutputId: string = ''; state: 'idle' | 'requesting-mic' | 'connecting' | 'connected' | 'error' = 'idle'; constructor(onStateChange: (state: string) => void) { this.sessionId = `web-${Math.random().toString(36).slice(2, 10)}-${Date.now().toString(36)}`; this.onStateChange = onStateChange; } setWebSocket(ws: WebSocket): void { this.ws = ws; } setInputDevice(deviceId: string): void { this.selectedInputId = deviceId; } setOutputDevice(deviceId: string): void { this.selectedOutputId = deviceId; if (this.remoteAudio && 'setSinkId' in this.remoteAudio) { (this.remoteAudio as any).setSinkId(deviceId).catch(() => {}); } } handleSignaling(msg: { type: string; sessionId?: string; sdp?: string; candidate?: any; error?: string }): void { if (msg.sessionId !== this.sessionId) return; if (msg.type === 'webrtc-answer' && msg.sdp) { this.handleAnswer(msg.sdp); } else if (msg.type === 'webrtc-ice' && msg.candidate) { this.handleRemoteIce(msg.candidate); } else if (msg.type === 'webrtc-error') { this.setState('error'); console.error('[webrtc] server error:', msg.error); } } async startCall(): Promise { this.setState('requesting-mic'); const audioConstraints: MediaTrackConstraints = { echoCancellation: true, noiseSuppression: true, autoGainControl: true, }; if (this.selectedInputId) { audioConstraints.deviceId = { exact: this.selectedInputId }; } try { this.localStream = await navigator.mediaDevices.getUserMedia({ audio: audioConstraints, video: false, }); } catch (err) { console.error('[webrtc] mic access denied:', err); this.setState('error'); return; } this.setState('connecting'); // Set up AudioContext for level meters. this.audioCtx = new AudioContext(); const localSource = this.audioCtx.createMediaStreamSource(this.localStream); this.localAnalyser = this.audioCtx.createAnalyser(); this.localAnalyser.fftSize = 256; localSource.connect(this.localAnalyser); this.pc = new RTCPeerConnection({ iceServers: [{ urls: 'stun:stun.l.google.com:19302' }], }); // Add local audio track. for (const track of this.localStream.getTracks()) { this.pc.addTrack(track, this.localStream); } // Handle remote audio (incoming from SIP provider via proxy). this.pc.ontrack = (event) => { console.log('[webrtc] ontrack fired, streams:', event.streams.length); this.remoteAudio = new Audio(); this.remoteAudio.autoplay = true; this.remoteAudio.srcObject = event.streams[0] || new MediaStream([event.track]); // Route to selected output device. if (this.selectedOutputId && 'setSinkId' in this.remoteAudio) { (this.remoteAudio as any).setSinkId(this.selectedOutputId).catch(() => {}); } this.remoteAudio.play().catch((e) => console.warn('[webrtc] autoplay blocked:', e)); // Set up remote audio analyser for level meter. if (this.audioCtx && event.streams[0]) { const remoteSource = this.audioCtx.createMediaStreamSource(event.streams[0]); this.remoteAnalyser = this.audioCtx.createAnalyser(); this.remoteAnalyser.fftSize = 256; remoteSource.connect(this.remoteAnalyser); } }; // Send ICE candidates to server. this.pc.onicecandidate = (event) => { if (event.candidate) { this.wsSend({ type: 'webrtc-ice', sessionId: this.sessionId, candidate: event.candidate.toJSON(), }); } }; this.pc.onconnectionstatechange = () => { if (this.pc?.connectionState === 'connected') { this.setState('connected'); } else if (this.pc?.connectionState === 'failed') { this.setState('error'); } }; // Create offer and send to server. const offer = await this.pc.createOffer(); await this.pc.setLocalDescription(offer); this.wsSend({ type: 'webrtc-offer', sessionId: this.sessionId, sdp: offer.sdp, }); } /** Get current mic input level (0-1). */ getLocalLevel(): number { return this.getLevel(this.localAnalyser); } /** Get current remote audio level (0-1). */ getRemoteLevel(): number { return this.getLevel(this.remoteAnalyser); } private getLevel(analyser: AnalyserNode | null): number { if (!analyser) return 0; const data = new Uint8Array(analyser.frequencyBinCount); analyser.getByteTimeDomainData(data); let sum = 0; for (let i = 0; i < data.length; i++) { const v = (data[i] - 128) / 128; sum += v * v; } return Math.sqrt(sum / data.length); } hangup(): void { this.wsSend({ type: 'webrtc-hangup', sessionId: this.sessionId, }); this.cleanup(); this.setState('idle'); } private async handleAnswer(sdp: string): Promise { if (!this.pc) return; await this.pc.setRemoteDescription({ type: 'answer', sdp }); } private async handleRemoteIce(candidate: RTCIceCandidateInit): Promise { if (!this.pc) return; try { await this.pc.addIceCandidate(candidate); } catch (err) { console.error('[webrtc] ice error:', err); } } private setState(state: typeof this.state): void { this.state = state; this.onStateChange(state); } private cleanup(): void { this.localStream?.getTracks().forEach((t) => t.stop()); this.localStream = null; this.localAnalyser = null; this.remoteAnalyser = null; if (this.remoteAudio) { this.remoteAudio.pause(); this.remoteAudio.srcObject = null; this.remoteAudio = null; } this.audioCtx?.close().catch(() => {}); this.audioCtx = null; this.pc?.close(); this.pc = null; } private wsSend(data: unknown): void { if (this.ws?.readyState === WebSocket.OPEN) { this.ws.send(JSON.stringify(data)); } } get id(): string { return this.sessionId; } } /** Enumerate audio input/output devices. */ export async function getAudioDevices(): Promise { try { // Need to request mic first to get labels. const stream = await navigator.mediaDevices.getUserMedia({ audio: true }); stream.getTracks().forEach((t) => t.stop()); const devices = await navigator.mediaDevices.enumerateDevices(); return { inputs: devices.filter((d) => d.kind === 'audioinput'), outputs: devices.filter((d) => d.kind === 'audiooutput'), }; } catch { return { inputs: [], outputs: [] }; } }