Files
siprouter/ts/call/webrtc-leg.ts
Juergen Kunz f3e1c96872 initial commit — SIP B2BUA + WebRTC bridge with Rust codec engine
Full-featured SIP router with multi-provider trunking, browser softphone
via WebRTC, real-time Opus/G.722/PCM transcoding in Rust, RNNoise ML
noise suppression, Kokoro neural TTS announcements, and a Lit-based
web dashboard with live call monitoring and REST API.
2026-04-09 23:03:55 +00:00

418 lines
15 KiB
TypeScript

/**
* WebRtcLeg — a WebRTC connection from the Call hub to a browser client.
*
* Wraps a werift RTCPeerConnection and handles:
* - WebRTC offer/answer/ICE negotiation
* - Opus <-> G.722/PCMU/PCMA transcoding via Rust IPC
* - RTP header rebuilding with correct PT, timestamp, SSRC
*/
import dgram from 'node:dgram';
import { Buffer } from 'node:buffer';
import { WebSocket } from 'ws';
import type { IEndpoint } from '../sip/index.ts';
import type { TLegState, ILegStatus } from './types.ts';
import type { ILeg } from './leg.ts';
import { rtpClockIncrement, buildRtpHeader, codecDisplayName } from './leg.ts';
import { createTranscoder, OPUS_PT } from '../codec.ts';
import type { IRtpTranscoder } from '../codec.ts';
import { createSession, destroySession } from '../opusbridge.ts';
import type { SipDialog } from '../sip/index.ts';
import type { SipMessage } from '../sip/index.ts';
// ---------------------------------------------------------------------------
// WebRtcLeg config
// ---------------------------------------------------------------------------
export interface IWebRtcLegConfig {
/** The browser's WebSocket connection. */
ws: WebSocket;
/** The browser's session ID. */
sessionId: string;
/** RTP port and socket (pre-allocated from the pool). */
rtpPort: number;
rtpSock: dgram.Socket;
/** Logging function. */
log: (msg: string) => void;
}
// ---------------------------------------------------------------------------
// WebRtcLeg
// ---------------------------------------------------------------------------
export class WebRtcLeg implements ILeg {
readonly id: string;
readonly type = 'webrtc' as const;
state: TLegState = 'inviting';
readonly sessionId: string;
/** The werift RTCPeerConnection instance. */
private pc: any = null;
/** RTP socket for bridging to SIP. */
readonly rtpSock: dgram.Socket;
readonly rtpPort: number;
/** Remote media endpoint (the other side of the bridge, set by Call). */
remoteMedia: IEndpoint | null = null;
/** Negotiated WebRTC codec payload type. */
codec: number | null = null;
/** Transcoders for WebRTC <-> SIP conversion. */
transcoder: IRtpTranscoder | null = null; // used by Call for fan-out
private toSipTranscoder: IRtpTranscoder | null = null;
private fromSipTranscoder: IRtpTranscoder | null = null;
/** RTP counters for outgoing (to SIP) direction. */
private toSipSeq = 0;
private toSipTs = 0;
private toSipSsrc = (Math.random() * 0xffffffff) >>> 0;
/** RTP counters for incoming (from SIP) direction.
* Initialized to random values so announcements and provider audio share
* a continuous sequence — prevents the browser jitter buffer from discarding
* packets after the announcement→provider transition. */
readonly fromSipCounters = {
seq: Math.floor(Math.random() * 0xffff),
ts: Math.floor(Math.random() * 0xffffffff),
};
fromSipSsrc = (Math.random() * 0xffffffff) >>> 0;
/** Packet counters. */
pktSent = 0;
pktReceived = 0;
/** Callback set by Call. */
onRtpReceived: ((data: Buffer) => void) | null = null;
/** Callback to send transcoded RTP to the provider via the SipLeg's socket.
* Set by CallManager when the bridge is established. If null, falls back to own rtpSock. */
onSendToProvider: ((data: Buffer, dest: IEndpoint) => void) | null = null;
/** Lifecycle callbacks. */
onConnected: ((leg: WebRtcLeg) => void) | null = null;
onTerminated: ((leg: WebRtcLeg) => void) | null = null;
/** Cancel handle for an in-progress announcement. */
announcementCancel: (() => void) | null = null;
private ws: WebSocket;
private config: IWebRtcLegConfig;
private pendingIceCandidates: any[] = [];
// SipDialog is not applicable for WebRTC legs.
readonly dialog: SipDialog | null = null;
readonly sipCallId: string;
constructor(id: string, config: IWebRtcLegConfig) {
this.id = id;
this.sessionId = config.sessionId;
this.ws = config.ws;
this.rtpSock = config.rtpSock;
this.rtpPort = config.rtpPort;
this.config = config;
this.sipCallId = `webrtc-${id}`;
// Log RTP arriving on this socket (symmetric RTP from provider).
// Audio forwarding is handled by the Call hub: SipLeg → forwardRtp → WebRtcLeg.sendRtp.
// We do NOT transcode here to avoid double-processing (the SipLeg also receives these packets).
let sipRxCount = 0;
this.rtpSock.on('message', (data: Buffer) => {
sipRxCount++;
if (sipRxCount === 1 || sipRxCount === 50 || sipRxCount % 500 === 0) {
this.config.log(`[webrtc-leg:${this.id}] SIP->browser rtp #${sipRxCount} (${data.length}b) [symmetric, ignored]`);
}
});
}
// -------------------------------------------------------------------------
// WebRTC offer/answer
// -------------------------------------------------------------------------
/**
* Handle a WebRTC offer from the browser. Creates the PeerConnection,
* sets remote offer, creates answer, and sends it back.
*/
async handleOffer(offerSdp: string): Promise<void> {
this.config.log(`[webrtc-leg:${this.id}] received offer`);
try {
const werift = await import('werift');
this.pc = new werift.RTCPeerConnection({ iceServers: [] });
// Add sendrecv transceiver before setRemoteDescription.
this.pc.addTransceiver('audio', { direction: 'sendrecv' });
// Handle incoming audio from browser.
this.pc.ontrack = (event: any) => {
const track = event.track;
this.config.log(`[webrtc-leg:${this.id}] got track: ${track.kind}`);
let rxCount = 0;
track.onReceiveRtp.subscribe((rtp: any) => {
if (!this.remoteMedia) return;
rxCount++;
if (rxCount === 1 || rxCount === 50 || rxCount % 500 === 0) {
this.config.log(`[webrtc-leg:${this.id}] browser->SIP rtp #${rxCount}`);
}
this.forwardToSip(rtp, rxCount);
});
};
// ICE candidate handling.
this.pc.onicecandidate = (candidate: any) => {
if (candidate) {
const json = candidate.toJSON?.() || candidate;
this.wsSend({ type: 'webrtc-ice', sessionId: this.sessionId, candidate: json });
}
};
this.pc.onconnectionstatechange = () => {
this.config.log(`[webrtc-leg:${this.id}] connection state: ${this.pc.connectionState}`);
if (this.pc.connectionState === 'connected') {
this.state = 'connected';
this.onConnected?.(this);
} else if (this.pc.connectionState === 'failed' || this.pc.connectionState === 'closed') {
this.state = 'terminated';
this.onTerminated?.(this);
}
};
if (this.pc.oniceconnectionstatechange !== undefined) {
this.pc.oniceconnectionstatechange = () => {
this.config.log(`[webrtc-leg:${this.id}] ICE state: ${this.pc.iceConnectionState}`);
};
}
// Set remote offer and create answer.
await this.pc.setRemoteDescription({ type: 'offer', sdp: offerSdp });
const answer = await this.pc.createAnswer();
await this.pc.setLocalDescription(answer);
const sdp: string = this.pc.localDescription!.sdp;
// Detect negotiated codec.
const mAudio = sdp.match(/m=audio\s+\d+\s+\S+\s+(\d+)/);
if (mAudio) {
this.codec = parseInt(mAudio[1], 10);
this.config.log(`[webrtc-leg:${this.id}] negotiated audio PT=${this.codec}`);
}
// Extract sender SSRC from SDP.
const ssrcMatch = sdp.match(/a=ssrc:(\d+)\s/);
if (ssrcMatch) {
this.fromSipSsrc = parseInt(ssrcMatch[1], 10);
}
// Also try from sender object.
const senders = this.pc.getSenders();
if (senders[0]) {
const senderSsrc = (senders[0] as any).ssrc ?? (senders[0] as any)._ssrc;
if (senderSsrc) this.fromSipSsrc = senderSsrc;
}
// Send answer to browser.
this.wsSend({ type: 'webrtc-answer', sessionId: this.sessionId, sdp });
this.config.log(`[webrtc-leg:${this.id}] sent answer, rtp port=${this.rtpPort}`);
// Process buffered ICE candidates.
for (const c of this.pendingIceCandidates) {
try { await this.pc.addIceCandidate(c); } catch { /* ignore */ }
}
this.pendingIceCandidates = [];
} catch (err: any) {
this.config.log(`[webrtc-leg:${this.id}] offer error: ${err.message}`);
this.wsSend({ type: 'webrtc-error', sessionId: this.sessionId, error: err.message });
this.state = 'terminated';
this.onTerminated?.(this);
}
}
/** Add an ICE candidate from the browser. */
async addIceCandidate(candidate: any): Promise<void> {
if (!this.pc) {
this.pendingIceCandidates.push(candidate);
return;
}
try {
if (candidate) await this.pc.addIceCandidate(candidate);
} catch (err: any) {
this.config.log(`[webrtc-leg:${this.id}] ICE error: ${err.message}`);
}
}
// -------------------------------------------------------------------------
// Transcoding setup
// -------------------------------------------------------------------------
/** Codec session ID for isolated Rust codec state (unique per leg). */
private codecSessionId = `webrtc-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
/**
* Set up transcoders for bridging between WebRTC and SIP codecs.
* Called by the Call when the remote media endpoint is known.
* Creates an isolated Rust codec session so concurrent calls don't
* corrupt each other's stateful codec state (Opus/G.722 ADPCM).
*/
async setupTranscoders(sipPT: number): Promise<void> {
const webrtcPT = this.codec ?? OPUS_PT;
// Create isolated codec session for this leg.
await createSession(this.codecSessionId);
this.toSipTranscoder = createTranscoder(webrtcPT, sipPT, this.codecSessionId, 'to_sip');
this.fromSipTranscoder = createTranscoder(sipPT, webrtcPT, this.codecSessionId, 'to_browser');
const mode = this.toSipTranscoder ? `transcoding PT ${webrtcPT}<->${sipPT}` : `pass-through PT ${webrtcPT}`;
this.config.log(`[webrtc-leg:${this.id}] ${mode} (session: ${this.codecSessionId})`);
}
// -------------------------------------------------------------------------
// RTP forwarding
// -------------------------------------------------------------------------
/** Forward RTP from SIP side to browser via WebRTC. */
private forwardToBrowser(data: Buffer, count: number): void {
const sender = this.pc?.getSenders()[0];
if (!sender) return;
if (this.fromSipTranscoder && data.length > 12) {
const payload = Buffer.from(data.subarray(12));
// Stop announcement if still playing — provider audio takes over.
if (this.announcementCancel) {
this.announcementCancel();
this.announcementCancel = null;
}
// Capture seq/ts BEFORE async transcode to preserve ordering.
const toPT = this.fromSipTranscoder.toPT;
const seq = this.fromSipCounters.seq++;
const ts = this.fromSipCounters.ts;
this.fromSipCounters.ts += rtpClockIncrement(toPT);
const result = this.fromSipTranscoder.payload(payload);
const sendTranscoded = (transcoded: Buffer) => {
if (transcoded.length === 0) return; // transcoding failed
try {
const hdr = buildRtpHeader(toPT, seq, ts, this.fromSipSsrc, false);
const out = Buffer.concat([hdr, transcoded]);
const r = sender.sendRtp(out);
if (r instanceof Promise) r.catch(() => {});
} catch { /* ignore */ }
};
if (result instanceof Promise) result.then(sendTranscoded).catch(() => {});
else sendTranscoded(result);
} else if (!this.fromSipTranscoder) {
// No transcoder — either same codec or not set up yet.
// Only forward if we don't expect transcoding.
if (this.codec === null) {
try { sender.sendRtp(data); } catch { /* ignore */ }
}
}
}
/** Forward RTP from browser to SIP side. */
private forwardToSip(rtp: any, count: number): void {
if (!this.remoteMedia) return;
if (this.toSipTranscoder) {
const payload: Buffer = rtp.payload;
if (!payload || payload.length === 0) return;
// Capture seq/ts BEFORE async transcode to preserve ordering.
const toPT = this.toSipTranscoder.toPT;
const seq = this.toSipSeq++;
const ts = this.toSipTs;
this.toSipTs += rtpClockIncrement(toPT);
const result = this.toSipTranscoder.payload(payload);
const sendTranscoded = (transcoded: Buffer) => {
if (transcoded.length === 0) return; // transcoding failed
const hdr = buildRtpHeader(toPT, seq, ts, this.toSipSsrc, false);
const out = Buffer.concat([hdr, transcoded]);
if (this.onSendToProvider) {
this.onSendToProvider(out, this.remoteMedia!);
} else {
this.rtpSock.send(out, this.remoteMedia!.port, this.remoteMedia!.address);
}
this.pktSent++;
};
if (result instanceof Promise) result.then(sendTranscoded).catch(() => {});
else sendTranscoded(result);
} else if (this.codec === null) {
// Same codec (no transcoding needed) — pass through.
const raw = rtp.serialize();
if (this.onSendToProvider) {
this.onSendToProvider(raw, this.remoteMedia);
} else {
this.rtpSock.send(raw, this.remoteMedia.port, this.remoteMedia.address);
}
this.pktSent++;
}
// If codec is set but transcoder is null, drop the packet — transcoder not ready yet.
// This prevents raw Opus from being sent to a G.722 endpoint.
}
/**
* Send RTP to the browser via WebRTC (used by Call hub for fan-out).
* This transcodes and sends through the PeerConnection, NOT to a UDP address.
*/
sendRtp(data: Buffer): void {
this.forwardToBrowser(data, this.pktSent);
this.pktSent++;
}
/**
* Send a pre-encoded RTP packet directly to the browser via PeerConnection.
* Used for announcements — the packet must already be in the correct codec (Opus).
*/
sendDirectToBrowser(pkt: Buffer): void {
const sender = this.pc?.getSenders()[0];
if (!sender) return;
try {
const r = sender.sendRtp(pkt);
if (r instanceof Promise) r.catch(() => {});
} catch { /* ignore */ }
}
/** No-op: WebRTC legs don't process SIP messages. */
handleSipMessage(_msg: SipMessage, _rinfo: IEndpoint): void {
// WebRTC legs don't handle SIP messages.
}
// -------------------------------------------------------------------------
// Lifecycle
// -------------------------------------------------------------------------
teardown(): void {
this.state = 'terminated';
try { this.pc?.close(); } catch { /* ignore */ }
this.pc = null;
// Destroy the isolated Rust codec session for this leg.
destroySession(this.codecSessionId).catch(() => {});
// Note: RTP socket is NOT closed here — the RtpPortPool manages that.
}
getStatus(): ILegStatus {
return {
id: this.id,
type: this.type,
state: this.state,
remoteMedia: this.remoteMedia,
rtpPort: this.rtpPort,
pktSent: this.pktSent,
pktReceived: this.pktReceived,
codec: codecDisplayName(this.codec),
transcoding: this.toSipTranscoder !== null || this.fromSipTranscoder !== null,
};
}
// -------------------------------------------------------------------------
// Helpers
// -------------------------------------------------------------------------
private wsSend(data: unknown): void {
try {
if (this.ws.readyState === WebSocket.OPEN) {
this.ws.send(JSON.stringify(data));
}
} catch { /* ignore */ }
}
}