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.
This commit is contained in:
1139
ts/call/call-manager.ts
Normal file
1139
ts/call/call-manager.ts
Normal file
File diff suppressed because it is too large
Load Diff
255
ts/call/call.ts
Normal file
255
ts/call/call.ts
Normal file
@@ -0,0 +1,255 @@
|
||||
/**
|
||||
* Call — the hub entity in the hub model.
|
||||
*
|
||||
* A Call owns N legs and bridges their media. For 2-party calls, RTP packets
|
||||
* from leg A are forwarded to leg B and vice versa. For N>2 party calls,
|
||||
* packets from each leg are forwarded to all other legs (fan-out).
|
||||
*
|
||||
* Transcoding is applied per-leg when codecs differ.
|
||||
*/
|
||||
|
||||
import { Buffer } from 'node:buffer';
|
||||
import type { ILeg } from './leg.ts';
|
||||
import type { TCallState, TCallDirection, ICallStatus } from './types.ts';
|
||||
import { RtpPortPool } from './rtp-port-pool.ts';
|
||||
import type { SipLeg } from './sip-leg.ts';
|
||||
|
||||
export class Call {
|
||||
readonly id: string;
|
||||
state: TCallState = 'setting-up';
|
||||
direction: TCallDirection;
|
||||
readonly createdAt: number;
|
||||
|
||||
callerNumber: string | null = null;
|
||||
calleeNumber: string | null = null;
|
||||
providerUsed: string | null = null;
|
||||
|
||||
/** All legs in this call. */
|
||||
private legs = new Map<string, ILeg>();
|
||||
|
||||
/** Codec payload type for the "native" audio in the call (usually the first SIP leg's codec). */
|
||||
private nativeCodec: number | null = null;
|
||||
|
||||
/** Port pool reference for cleanup. */
|
||||
private portPool: RtpPortPool;
|
||||
private log: (msg: string) => void;
|
||||
private onChange: ((call: Call) => void) | null = null;
|
||||
|
||||
constructor(options: {
|
||||
id: string;
|
||||
direction: TCallDirection;
|
||||
portPool: RtpPortPool;
|
||||
log: (msg: string) => void;
|
||||
onChange?: (call: Call) => void;
|
||||
}) {
|
||||
this.id = options.id;
|
||||
this.direction = options.direction;
|
||||
this.createdAt = Date.now();
|
||||
this.portPool = options.portPool;
|
||||
this.log = options.log;
|
||||
this.onChange = options.onChange ?? null;
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Leg management
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
/** Add a leg to this call and wire up media forwarding. */
|
||||
addLeg(leg: ILeg): void {
|
||||
this.legs.set(leg.id, leg);
|
||||
|
||||
// Wire up RTP forwarding: when this leg receives a packet, forward to all other legs.
|
||||
leg.onRtpReceived = (data: Buffer) => {
|
||||
this.forwardRtp(leg.id, data);
|
||||
};
|
||||
|
||||
this.log(`[call:${this.id}] added leg ${leg.id} (${leg.type}), total=${this.legs.size}`);
|
||||
this.updateState();
|
||||
}
|
||||
|
||||
/** Remove a leg from this call, tear it down, and release its port. */
|
||||
removeLeg(legId: string): void {
|
||||
const leg = this.legs.get(legId);
|
||||
if (!leg) return;
|
||||
|
||||
leg.onRtpReceived = null;
|
||||
leg.teardown();
|
||||
if (leg.rtpPort) {
|
||||
this.portPool.release(leg.rtpPort);
|
||||
}
|
||||
this.legs.delete(legId);
|
||||
|
||||
this.log(`[call:${this.id}] removed leg ${legId}, total=${this.legs.size}`);
|
||||
this.updateState();
|
||||
}
|
||||
|
||||
getLeg(legId: string): ILeg | null {
|
||||
return this.legs.get(legId) ?? null;
|
||||
}
|
||||
|
||||
getLegs(): ILeg[] {
|
||||
return [...this.legs.values()];
|
||||
}
|
||||
|
||||
getLegByType(type: string): ILeg | null {
|
||||
for (const leg of this.legs.values()) {
|
||||
if (leg.type === type) return leg;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
getLegBySipCallId(sipCallId: string): ILeg | null {
|
||||
for (const leg of this.legs.values()) {
|
||||
if (leg.sipCallId === sipCallId) return leg;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
get legCount(): number {
|
||||
return this.legs.size;
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Media forwarding (the hub)
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
private forwardRtp(fromLegId: string, data: Buffer): void {
|
||||
for (const [id, leg] of this.legs) {
|
||||
if (id === fromLegId) continue;
|
||||
if (leg.state !== 'connected') continue;
|
||||
|
||||
// For WebRTC legs, sendRtp calls forwardToBrowser which handles transcoding internally.
|
||||
// For SIP legs, forward the raw packet (same codec path) or let the leg handle it.
|
||||
// The Call hub does NOT transcode — that's the leg's responsibility.
|
||||
leg.sendRtp(data);
|
||||
}
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// State management
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
private updateState(): void {
|
||||
if (this.state === 'terminated' || this.state === 'terminating') return;
|
||||
|
||||
const legs = [...this.legs.values()];
|
||||
if (legs.length === 0) {
|
||||
this.state = 'terminated';
|
||||
} else if (legs.every((l) => l.state === 'terminated')) {
|
||||
this.state = 'terminated';
|
||||
} else if (legs.some((l) => l.state === 'connected') && legs.filter((l) => l.state !== 'terminated').length >= 2) {
|
||||
this.state = 'connected';
|
||||
} else if (legs.some((l) => l.state === 'ringing')) {
|
||||
this.state = 'ringing';
|
||||
} else {
|
||||
this.state = 'setting-up';
|
||||
}
|
||||
|
||||
this.onChange?.(this);
|
||||
}
|
||||
|
||||
/** Notify the call that a leg's state has changed. */
|
||||
notifyLegStateChange(_leg: ILeg): void {
|
||||
this.updateState();
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Hangup
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
/** Tear down all legs and terminate the call. */
|
||||
hangup(): void {
|
||||
if (this.state === 'terminated' || this.state === 'terminating') return;
|
||||
this.state = 'terminating';
|
||||
this.log(`[call:${this.id}] hanging up (${this.legs.size} legs)`);
|
||||
|
||||
for (const [id, leg] of this.legs) {
|
||||
// Send BYE/CANCEL for SIP legs.
|
||||
if (leg.type === 'sip-device' || leg.type === 'sip-provider') {
|
||||
(leg as SipLeg).sendHangup();
|
||||
}
|
||||
leg.teardown();
|
||||
if (leg.rtpPort) {
|
||||
this.portPool.release(leg.rtpPort);
|
||||
}
|
||||
}
|
||||
this.legs.clear();
|
||||
|
||||
this.state = 'terminated';
|
||||
this.onChange?.(this);
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle a BYE from one leg — tear down the other legs.
|
||||
* Called by CallManager when a SipLeg receives a BYE.
|
||||
*/
|
||||
handleLegTerminated(terminatedLegId: string): void {
|
||||
const terminatedLeg = this.legs.get(terminatedLegId);
|
||||
if (!terminatedLeg) return;
|
||||
|
||||
// Remove the terminated leg.
|
||||
terminatedLeg.onRtpReceived = null;
|
||||
if (terminatedLeg.rtpPort) {
|
||||
this.portPool.release(terminatedLeg.rtpPort);
|
||||
}
|
||||
this.legs.delete(terminatedLegId);
|
||||
|
||||
// If this is a 2-party call, hang up the other leg too.
|
||||
if (this.legs.size <= 1) {
|
||||
for (const [id, leg] of this.legs) {
|
||||
if (leg.type === 'sip-device' || leg.type === 'sip-provider') {
|
||||
(leg as SipLeg).sendHangup();
|
||||
}
|
||||
leg.teardown();
|
||||
if (leg.rtpPort) {
|
||||
this.portPool.release(leg.rtpPort);
|
||||
}
|
||||
}
|
||||
this.legs.clear();
|
||||
this.state = 'terminated';
|
||||
this.log(`[call:${this.id}] terminated`);
|
||||
this.onChange?.(this);
|
||||
} else {
|
||||
this.log(`[call:${this.id}] leg ${terminatedLegId} removed, ${this.legs.size} remaining`);
|
||||
this.updateState();
|
||||
}
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Transfer
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Detach a leg from this call (without tearing it down).
|
||||
* The leg can then be added to another call.
|
||||
*/
|
||||
detachLeg(legId: string): ILeg | null {
|
||||
const leg = this.legs.get(legId);
|
||||
if (!leg) return null;
|
||||
|
||||
leg.onRtpReceived = null;
|
||||
this.legs.delete(legId);
|
||||
|
||||
this.log(`[call:${this.id}] detached leg ${legId}`);
|
||||
this.updateState();
|
||||
return leg;
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Status
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
getStatus(): ICallStatus {
|
||||
return {
|
||||
id: this.id,
|
||||
state: this.state,
|
||||
direction: this.direction,
|
||||
callerNumber: this.callerNumber,
|
||||
calleeNumber: this.calleeNumber,
|
||||
providerUsed: this.providerUsed,
|
||||
createdAt: this.createdAt,
|
||||
duration: Math.floor((Date.now() - this.createdAt) / 1000),
|
||||
legs: [...this.legs.values()].map((l) => l.getStatus()),
|
||||
};
|
||||
}
|
||||
}
|
||||
12
ts/call/index.ts
Normal file
12
ts/call/index.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
export type { TCallState, TLegState, TLegType, TCallDirection, ICallStatus, ILegStatus, ICallHistoryEntry } from './types.ts';
|
||||
export type { ILeg } from './leg.ts';
|
||||
export { rtpClockIncrement, buildRtpHeader, codecDisplayName } from './leg.ts';
|
||||
export { RtpPortPool } from './rtp-port-pool.ts';
|
||||
export type { IRtpAllocation } from './rtp-port-pool.ts';
|
||||
export { SipLeg } from './sip-leg.ts';
|
||||
export type { ISipLegConfig } from './sip-leg.ts';
|
||||
export { WebRtcLeg } from './webrtc-leg.ts';
|
||||
export type { IWebRtcLegConfig } from './webrtc-leg.ts';
|
||||
export { Call } from './call.ts';
|
||||
export { CallManager } from './call-manager.ts';
|
||||
export type { ICallManagerConfig } from './call-manager.ts';
|
||||
104
ts/call/leg.ts
Normal file
104
ts/call/leg.ts
Normal file
@@ -0,0 +1,104 @@
|
||||
/**
|
||||
* ILeg interface — abstract connection from a Call hub to an endpoint.
|
||||
*
|
||||
* Concrete implementations: SipLeg (SIP devices + providers) and WebRtcLeg (browsers).
|
||||
* Shared RTP utilities (header building, clock rates) are also defined here.
|
||||
*/
|
||||
|
||||
import { Buffer } from 'node:buffer';
|
||||
import type dgram from 'node:dgram';
|
||||
import type { IEndpoint } from '../sip/index.ts';
|
||||
import type { TLegState, TLegType, ILegStatus } from './types.ts';
|
||||
import type { IRtpTranscoder } from '../codec.ts';
|
||||
import type { SipDialog } from '../sip/index.ts';
|
||||
import type { SipMessage } from '../sip/index.ts';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// ILeg interface
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface ILeg {
|
||||
readonly id: string;
|
||||
readonly type: TLegType;
|
||||
state: TLegState;
|
||||
|
||||
/** The SIP Call-ID used by this leg (for CallManager routing). */
|
||||
readonly sipCallId: string;
|
||||
|
||||
/** Where this leg sends/receives RTP. */
|
||||
readonly rtpPort: number | null;
|
||||
readonly rtpSock: dgram.Socket | null;
|
||||
remoteMedia: IEndpoint | null;
|
||||
|
||||
/** Negotiated codec payload type (e.g. 9 = G.722, 111 = Opus). */
|
||||
codec: number | null;
|
||||
|
||||
/** Transcoder for converting to this leg's codec (set by Call when codecs differ). */
|
||||
transcoder: IRtpTranscoder | null;
|
||||
|
||||
/** Packet counters. */
|
||||
pktSent: number;
|
||||
pktReceived: number;
|
||||
|
||||
/** SIP dialog (SipLegs only, null for WebRtcLegs). */
|
||||
readonly dialog: SipDialog | null;
|
||||
|
||||
/**
|
||||
* Send an RTP packet toward this leg's remote endpoint.
|
||||
* If a transcoder is set, the Call should transcode before calling this.
|
||||
*/
|
||||
sendRtp(data: Buffer): void;
|
||||
|
||||
/**
|
||||
* Callback set by the owning Call — invoked when this leg receives an RTP packet.
|
||||
* The Call uses this to forward to other legs.
|
||||
*/
|
||||
onRtpReceived: ((data: Buffer) => void) | null;
|
||||
|
||||
/**
|
||||
* Handle an incoming SIP message routed to this leg (SipLegs only).
|
||||
* Returns a SipMessage response if one needs to be sent, or null.
|
||||
*/
|
||||
handleSipMessage(msg: SipMessage, rinfo: IEndpoint): void;
|
||||
|
||||
/** Release all resources (sockets, peer connections, etc.). */
|
||||
teardown(): void;
|
||||
|
||||
/** Status snapshot for the dashboard. */
|
||||
getStatus(): ILegStatus;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Shared RTP utilities
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** RTP clock increment per 20ms frame for each codec. */
|
||||
export function rtpClockIncrement(pt: number): number {
|
||||
if (pt === 111) return 960; // Opus: 48000 Hz x 0.02s
|
||||
if (pt === 9) return 160; // G.722: 8000 Hz x 0.02s (SDP clock rate quirk)
|
||||
return 160; // PCMU/PCMA: 8000 Hz x 0.02s
|
||||
}
|
||||
|
||||
/** Build a fresh RTP header with correct PT, timestamp, seq, SSRC. */
|
||||
export function buildRtpHeader(pt: number, seq: number, ts: number, ssrc: number, marker: boolean): Buffer {
|
||||
const hdr = Buffer.alloc(12);
|
||||
hdr[0] = 0x80; // V=2
|
||||
hdr[1] = (marker ? 0x80 : 0) | (pt & 0x7f);
|
||||
hdr.writeUInt16BE(seq & 0xffff, 2);
|
||||
hdr.writeUInt32BE(ts >>> 0, 4);
|
||||
hdr.writeUInt32BE(ssrc >>> 0, 8);
|
||||
return hdr;
|
||||
}
|
||||
|
||||
/** Codec name for status display. */
|
||||
export function codecDisplayName(pt: number | null): string | null {
|
||||
if (pt === null) return null;
|
||||
switch (pt) {
|
||||
case 0: return 'PCMU';
|
||||
case 8: return 'PCMA';
|
||||
case 9: return 'G.722';
|
||||
case 111: return 'Opus';
|
||||
case 101: return 'telephone-event';
|
||||
default: return `PT${pt}`;
|
||||
}
|
||||
}
|
||||
71
ts/call/rtp-port-pool.ts
Normal file
71
ts/call/rtp-port-pool.ts
Normal file
@@ -0,0 +1,71 @@
|
||||
/**
|
||||
* Unified RTP port pool — replaces the three separate allocators
|
||||
* in sipproxy.ts, calloriginator.ts, and webrtcbridge.ts.
|
||||
*
|
||||
* Allocates even-numbered UDP ports from a configured range.
|
||||
* Each allocation binds a dgram socket and returns it ready to use.
|
||||
*/
|
||||
|
||||
import dgram from 'node:dgram';
|
||||
|
||||
export interface IRtpAllocation {
|
||||
port: number;
|
||||
sock: dgram.Socket;
|
||||
}
|
||||
|
||||
export class RtpPortPool {
|
||||
private min: number;
|
||||
private max: number;
|
||||
private allocated = new Map<number, dgram.Socket>();
|
||||
private log: (msg: string) => void;
|
||||
|
||||
constructor(min: number, max: number, log: (msg: string) => void) {
|
||||
this.min = min % 2 === 0 ? min : min + 1; // ensure even start
|
||||
this.max = max;
|
||||
this.log = log;
|
||||
}
|
||||
|
||||
/**
|
||||
* Allocate an even-numbered port and bind a UDP socket to it.
|
||||
* Returns null if the pool is exhausted.
|
||||
*/
|
||||
allocate(): IRtpAllocation | null {
|
||||
for (let port = this.min; port < this.max; port += 2) {
|
||||
if (this.allocated.has(port)) continue;
|
||||
|
||||
const sock = dgram.createSocket('udp4');
|
||||
try {
|
||||
sock.bind(port, '0.0.0.0');
|
||||
} catch {
|
||||
try { sock.close(); } catch { /* ignore */ }
|
||||
continue;
|
||||
}
|
||||
this.allocated.set(port, sock);
|
||||
this.log(`[rtp-pool] allocated port ${port} (${this.allocated.size} in use)`);
|
||||
return { port, sock };
|
||||
}
|
||||
this.log('[rtp-pool] WARN: port pool exhausted');
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Release a port back to the pool and close its socket.
|
||||
*/
|
||||
release(port: number): void {
|
||||
const sock = this.allocated.get(port);
|
||||
if (!sock) return;
|
||||
try { sock.close(); } catch { /* ignore */ }
|
||||
this.allocated.delete(port);
|
||||
this.log(`[rtp-pool] released port ${port} (${this.allocated.size} in use)`);
|
||||
}
|
||||
|
||||
/** Number of currently allocated ports. */
|
||||
get size(): number {
|
||||
return this.allocated.size;
|
||||
}
|
||||
|
||||
/** Total capacity (number of even ports in range). */
|
||||
get capacity(): number {
|
||||
return Math.floor((this.max - this.min) / 2);
|
||||
}
|
||||
}
|
||||
622
ts/call/sip-leg.ts
Normal file
622
ts/call/sip-leg.ts
Normal file
@@ -0,0 +1,622 @@
|
||||
/**
|
||||
* SipLeg — a SIP connection from the Call hub to a device or provider.
|
||||
*
|
||||
* Wraps a SipDialog and an RTP socket. Handles:
|
||||
* - INVITE/ACK/BYE/CANCEL lifecycle
|
||||
* - SDP rewriting (LAN IP for devices, public IP for providers)
|
||||
* - Digest auth for provider legs (407/401)
|
||||
* - Early-media silence for providers with quirks
|
||||
* - Record-Route insertion for dialog-establishing requests
|
||||
*/
|
||||
|
||||
import dgram from 'node:dgram';
|
||||
import { Buffer } from 'node:buffer';
|
||||
import {
|
||||
SipMessage,
|
||||
SipDialog,
|
||||
buildSdp,
|
||||
parseSdpEndpoint,
|
||||
rewriteSdp,
|
||||
rewriteSipUri,
|
||||
parseDigestChallenge,
|
||||
computeDigestAuth,
|
||||
generateTag,
|
||||
} from '../sip/index.ts';
|
||||
import type { IEndpoint } from '../sip/index.ts';
|
||||
import type { IProviderConfig, IQuirks } from '../config.ts';
|
||||
import type { TLegState, TLegType, ILegStatus } from './types.ts';
|
||||
import type { ILeg } from './leg.ts';
|
||||
import { codecDisplayName } from './leg.ts';
|
||||
import type { IRtpTranscoder } from '../codec.ts';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// SipLeg config
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface ISipLegConfig {
|
||||
/** Whether this leg faces a device (LAN) or a provider (WAN). */
|
||||
role: 'device' | 'provider';
|
||||
|
||||
/** Proxy LAN IP (for SDP rewriting toward devices). */
|
||||
lanIp: string;
|
||||
/** Proxy LAN port (for Via, Contact, Record-Route). */
|
||||
lanPort: number;
|
||||
|
||||
/** Public IP (for SDP rewriting toward providers). */
|
||||
getPublicIp: () => string | null;
|
||||
|
||||
/** Send a SIP message via the main UDP socket. */
|
||||
sendSip: (buf: Buffer, dest: IEndpoint) => void;
|
||||
/** Logging function. */
|
||||
log: (msg: string) => void;
|
||||
|
||||
/** Provider config (for provider legs: auth, codecs, quirks, outbound proxy). */
|
||||
provider?: IProviderConfig;
|
||||
|
||||
/** The endpoint to send SIP messages to (device address or provider outbound proxy). */
|
||||
sipTarget: IEndpoint;
|
||||
|
||||
/** RTP port and socket (pre-allocated from the pool). */
|
||||
rtpPort: number;
|
||||
rtpSock: dgram.Socket;
|
||||
|
||||
/** Payload types to offer in SDP. */
|
||||
payloadTypes?: number[];
|
||||
|
||||
/** Registered AOR (for From header in provider leg). */
|
||||
getRegisteredAor?: () => string | null;
|
||||
/** SIP password (for digest auth). */
|
||||
getSipPassword?: () => string | null;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// SipLeg
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export class SipLeg implements ILeg {
|
||||
readonly id: string;
|
||||
readonly type: TLegType;
|
||||
state: TLegState = 'inviting';
|
||||
readonly config: ISipLegConfig;
|
||||
|
||||
/** The SIP dialog for this leg. */
|
||||
dialog: SipDialog | null = null;
|
||||
|
||||
/** Original INVITE (needed for CANCEL). */
|
||||
invite: SipMessage | null = null;
|
||||
|
||||
/** Original unauthenticated INVITE (for re-ACKing retransmitted 407s). */
|
||||
private origInvite: SipMessage | null = null;
|
||||
|
||||
/** Whether we've attempted digest auth on this leg. */
|
||||
private authAttempted = false;
|
||||
|
||||
/** RTP socket and port. */
|
||||
readonly rtpPort: number;
|
||||
readonly rtpSock: dgram.Socket;
|
||||
|
||||
/** Remote media endpoint (learned from SDP). */
|
||||
remoteMedia: IEndpoint | null = null;
|
||||
|
||||
/** Negotiated codec. */
|
||||
codec: number | null = null;
|
||||
|
||||
/** Transcoder (set by Call when codecs differ between legs). */
|
||||
transcoder: IRtpTranscoder | null = null;
|
||||
|
||||
/** Stable SSRC for this leg (used for silence + forwarded audio). */
|
||||
readonly ssrc: number = (Math.random() * 0xffffffff) >>> 0;
|
||||
|
||||
/** Packet counters. */
|
||||
pktSent = 0;
|
||||
pktReceived = 0;
|
||||
|
||||
/** Callback set by Call to receive RTP. */
|
||||
onRtpReceived: ((data: Buffer) => void) | null = null;
|
||||
|
||||
/** Silence stream timer (for provider quirks). */
|
||||
private silenceTimer: ReturnType<typeof setInterval> | null = null;
|
||||
|
||||
/** Callbacks for lifecycle events. */
|
||||
onStateChange: ((leg: SipLeg) => void) | null = null;
|
||||
onConnected: ((leg: SipLeg) => void) | null = null;
|
||||
onTerminated: ((leg: SipLeg) => void) | null = null;
|
||||
|
||||
constructor(id: string, config: ISipLegConfig) {
|
||||
this.id = id;
|
||||
this.type = config.role === 'device' ? 'sip-device' : 'sip-provider';
|
||||
this.config = config;
|
||||
this.rtpPort = config.rtpPort;
|
||||
this.rtpSock = config.rtpSock;
|
||||
|
||||
// Set up RTP receive handler.
|
||||
this.rtpSock.on('message', (data: Buffer, rinfo: dgram.RemoteInfo) => {
|
||||
this.pktReceived++;
|
||||
|
||||
// Learn remote media endpoint from first packet if not yet known.
|
||||
if (!this.remoteMedia) {
|
||||
this.remoteMedia = { address: rinfo.address, port: rinfo.port };
|
||||
this.config.log(`[sip-leg:${this.id}] learned remote media: ${rinfo.address}:${rinfo.port}`);
|
||||
}
|
||||
|
||||
// Forward to the Call hub.
|
||||
if (this.onRtpReceived) {
|
||||
this.onRtpReceived(data);
|
||||
}
|
||||
});
|
||||
|
||||
this.rtpSock.on('error', (e: Error) => {
|
||||
this.config.log(`[sip-leg:${this.id}] rtp error: ${e.message}`);
|
||||
});
|
||||
}
|
||||
|
||||
get sipCallId(): string {
|
||||
return this.dialog?.callId || 'no-dialog';
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Outbound INVITE (B2BUA mode — create a new dialog)
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Send an INVITE to establish this leg.
|
||||
* Creates a new SipDialog (UAC side).
|
||||
*/
|
||||
sendInvite(options: {
|
||||
fromUri: string;
|
||||
toUri: string;
|
||||
callId: string;
|
||||
fromTag?: string;
|
||||
fromDisplayName?: string;
|
||||
cseq?: number;
|
||||
extraHeaders?: [string, string][];
|
||||
}): void {
|
||||
const ip = this.type === 'sip-provider'
|
||||
? (this.config.getPublicIp() || this.config.lanIp)
|
||||
: this.config.lanIp;
|
||||
const pts = this.config.payloadTypes || [9, 0, 8, 101];
|
||||
|
||||
const sdp = buildSdp({ ip, port: this.rtpPort, payloadTypes: pts });
|
||||
|
||||
const invite = SipMessage.createRequest('INVITE', options.toUri, {
|
||||
via: { host: ip, port: this.config.lanPort },
|
||||
from: { uri: options.fromUri, displayName: options.fromDisplayName, tag: options.fromTag },
|
||||
to: { uri: options.toUri },
|
||||
callId: options.callId,
|
||||
cseq: options.cseq,
|
||||
contact: `<sip:${ip}:${this.config.lanPort}>`,
|
||||
body: sdp,
|
||||
contentType: 'application/sdp',
|
||||
extraHeaders: options.extraHeaders,
|
||||
});
|
||||
|
||||
this.invite = invite;
|
||||
this.dialog = SipDialog.fromUacInvite(invite, ip, this.config.lanPort);
|
||||
this.state = 'inviting';
|
||||
|
||||
this.config.log(`[sip-leg:${this.id}] INVITE -> ${this.config.sipTarget.address}:${this.config.sipTarget.port}`);
|
||||
this.config.sendSip(invite.serialize(), this.config.sipTarget);
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Passthrough mode — forward a SIP message with rewriting
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Accept an incoming INVITE as a UAS (for passthrough inbound calls).
|
||||
* Creates a SipDialog on the UAS side.
|
||||
*/
|
||||
acceptIncoming(invite: SipMessage): void {
|
||||
const localTag = generateTag();
|
||||
this.dialog = SipDialog.fromUasInvite(invite, localTag, this.config.lanIp, this.config.lanPort);
|
||||
this.invite = invite;
|
||||
this.state = 'inviting';
|
||||
|
||||
// Learn remote media from SDP.
|
||||
if (invite.hasSdpBody) {
|
||||
const ep = parseSdpEndpoint(invite.body);
|
||||
if (ep) {
|
||||
this.remoteMedia = ep;
|
||||
this.config.log(`[sip-leg:${this.id}] media from SDP: ${ep.address}:${ep.port}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Forward a SIP message through this leg with SDP rewriting.
|
||||
* Used for passthrough calls where the proxy relays messages.
|
||||
*/
|
||||
forwardMessage(msg: SipMessage, dest: IEndpoint): void {
|
||||
const rewriteIp = this.type === 'sip-provider'
|
||||
? (this.config.getPublicIp() || this.config.lanIp)
|
||||
: this.config.lanIp;
|
||||
|
||||
// Rewrite SDP if present.
|
||||
if (msg.hasSdpBody) {
|
||||
const { body, original } = rewriteSdp(msg.body, rewriteIp, this.rtpPort);
|
||||
msg.body = body;
|
||||
msg.updateContentLength();
|
||||
if (original) {
|
||||
this.remoteMedia = original;
|
||||
this.config.log(`[sip-leg:${this.id}] media from SDP rewrite: ${original.address}:${original.port}`);
|
||||
}
|
||||
}
|
||||
|
||||
// Record-Route for dialog-establishing requests.
|
||||
if (msg.isRequest && msg.isDialogEstablishing) {
|
||||
msg.prependHeader('Record-Route', `<sip:${this.config.lanIp}:${this.config.lanPort};lr>`);
|
||||
}
|
||||
|
||||
// Rewrite Contact.
|
||||
if (this.type === 'sip-provider') {
|
||||
const contact = msg.getHeader('Contact');
|
||||
if (contact) {
|
||||
const nc = rewriteSipUri(contact, rewriteIp, this.config.lanPort);
|
||||
if (nc !== contact) msg.setHeader('Contact', nc);
|
||||
}
|
||||
}
|
||||
|
||||
// Rewrite Request-URI for inbound messages going to device.
|
||||
if (this.type === 'sip-device' && msg.isRequest) {
|
||||
msg.setRequestUri(rewriteSipUri(msg.requestUri!, dest.address, dest.port));
|
||||
}
|
||||
|
||||
this.config.sendSip(msg.serialize(), dest);
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// SIP message handling (routed by CallManager)
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
handleSipMessage(msg: SipMessage, rinfo: IEndpoint): void {
|
||||
if (msg.isResponse) {
|
||||
this.handleResponse(msg, rinfo);
|
||||
} else {
|
||||
this.handleRequest(msg, rinfo);
|
||||
}
|
||||
}
|
||||
|
||||
private handleResponse(msg: SipMessage, _rinfo: IEndpoint): void {
|
||||
const code = msg.statusCode ?? 0;
|
||||
const method = msg.cseqMethod?.toUpperCase();
|
||||
|
||||
this.config.log(`[sip-leg:${this.id}] <- ${code} (${method})`);
|
||||
|
||||
if (method === 'INVITE') {
|
||||
this.handleInviteResponse(msg, code);
|
||||
}
|
||||
// BYE/CANCEL responses don't need action beyond logging.
|
||||
}
|
||||
|
||||
private handleInviteResponse(msg: SipMessage, code: number): void {
|
||||
// Handle retransmitted 407 for the original unauthenticated INVITE.
|
||||
if (this.authAttempted && this.dialog) {
|
||||
const responseCSeqNum = parseInt((msg.getHeader('CSeq') || '').split(/\s+/)[0], 10);
|
||||
if (responseCSeqNum < this.dialog.localCSeq && code >= 400) {
|
||||
if (this.origInvite) {
|
||||
const ack = buildNon2xxAck(this.origInvite, msg);
|
||||
this.config.sendSip(ack.serialize(), this.config.sipTarget);
|
||||
}
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Handle 407 Proxy Authentication Required.
|
||||
if (code === 407 && this.type === 'sip-provider') {
|
||||
this.handleAuthChallenge(msg);
|
||||
return;
|
||||
}
|
||||
|
||||
// Update dialog state.
|
||||
if (this.dialog) {
|
||||
this.dialog.processResponse(msg);
|
||||
}
|
||||
|
||||
if (code === 180 || code === 183) {
|
||||
this.state = 'ringing';
|
||||
this.onStateChange?.(this);
|
||||
} else if (code >= 200 && code < 300) {
|
||||
// ACK the 200 OK.
|
||||
if (this.dialog) {
|
||||
const ack = this.dialog.createAck();
|
||||
this.config.sendSip(ack.serialize(), this.config.sipTarget);
|
||||
this.config.log(`[sip-leg:${this.id}] ACK sent`);
|
||||
}
|
||||
|
||||
// If already connected (200 retransmit), just re-ACK.
|
||||
if (this.state === 'connected') {
|
||||
this.config.log(`[sip-leg:${this.id}] re-ACK (200 retransmit)`);
|
||||
return;
|
||||
}
|
||||
|
||||
// Learn media endpoint from SDP.
|
||||
if (msg.hasSdpBody) {
|
||||
const ep = parseSdpEndpoint(msg.body);
|
||||
if (ep) {
|
||||
this.remoteMedia = ep;
|
||||
this.config.log(`[sip-leg:${this.id}] media = ${ep.address}:${ep.port}`);
|
||||
}
|
||||
}
|
||||
|
||||
this.state = 'connected';
|
||||
this.config.log(`[sip-leg:${this.id}] CONNECTED`);
|
||||
|
||||
// Start silence for provider legs with early media quirks.
|
||||
if (this.type === 'sip-provider') {
|
||||
this.startSilence();
|
||||
}
|
||||
|
||||
// Prime the RTP path.
|
||||
if (this.remoteMedia) {
|
||||
this.primeRtp(this.remoteMedia);
|
||||
}
|
||||
|
||||
this.onConnected?.(this);
|
||||
this.onStateChange?.(this);
|
||||
} else if (code >= 300) {
|
||||
this.config.log(`[sip-leg:${this.id}] rejected ${code}`);
|
||||
this.state = 'terminated';
|
||||
if (this.dialog) this.dialog.terminate();
|
||||
this.onTerminated?.(this);
|
||||
this.onStateChange?.(this);
|
||||
}
|
||||
}
|
||||
|
||||
private handleAuthChallenge(msg: SipMessage): void {
|
||||
if (this.authAttempted) {
|
||||
this.config.log(`[sip-leg:${this.id}] 407 after auth attempt — credentials rejected`);
|
||||
this.state = 'terminated';
|
||||
if (this.dialog) this.dialog.terminate();
|
||||
this.onTerminated?.(this);
|
||||
return;
|
||||
}
|
||||
this.authAttempted = true;
|
||||
|
||||
const challenge = msg.getHeader('Proxy-Authenticate');
|
||||
if (!challenge) {
|
||||
this.config.log(`[sip-leg:${this.id}] 407 but no Proxy-Authenticate`);
|
||||
this.state = 'terminated';
|
||||
if (this.dialog) this.dialog.terminate();
|
||||
this.onTerminated?.(this);
|
||||
return;
|
||||
}
|
||||
|
||||
const parsed = parseDigestChallenge(challenge);
|
||||
if (!parsed) {
|
||||
this.config.log(`[sip-leg:${this.id}] could not parse digest challenge`);
|
||||
this.state = 'terminated';
|
||||
if (this.dialog) this.dialog.terminate();
|
||||
this.onTerminated?.(this);
|
||||
return;
|
||||
}
|
||||
|
||||
const password = this.config.getSipPassword?.();
|
||||
const aor = this.config.getRegisteredAor?.();
|
||||
if (!password || !aor) {
|
||||
this.config.log(`[sip-leg:${this.id}] 407 but no password or AOR`);
|
||||
this.state = 'terminated';
|
||||
if (this.dialog) this.dialog.terminate();
|
||||
this.onTerminated?.(this);
|
||||
return;
|
||||
}
|
||||
|
||||
const username = aor.replace(/^sips?:/, '').split('@')[0];
|
||||
const destUri = this.invite?.requestUri || '';
|
||||
|
||||
const authValue = computeDigestAuth({
|
||||
username,
|
||||
password,
|
||||
realm: parsed.realm,
|
||||
nonce: parsed.nonce,
|
||||
method: 'INVITE',
|
||||
uri: destUri,
|
||||
algorithm: parsed.algorithm,
|
||||
opaque: parsed.opaque,
|
||||
});
|
||||
|
||||
// ACK the 407.
|
||||
if (this.invite) {
|
||||
const ack407 = buildNon2xxAck(this.invite, msg);
|
||||
this.config.sendSip(ack407.serialize(), this.config.sipTarget);
|
||||
this.config.log(`[sip-leg:${this.id}] ACK-407 sent`);
|
||||
}
|
||||
|
||||
// Keep original INVITE for re-ACKing retransmitted 407s.
|
||||
this.origInvite = this.invite;
|
||||
|
||||
// Resend INVITE with auth, same From tag, incremented CSeq.
|
||||
const ip = this.config.getPublicIp() || this.config.lanIp;
|
||||
const fromTag = this.dialog!.localTag;
|
||||
const pts = this.config.payloadTypes || [9, 0, 8, 101];
|
||||
|
||||
const sdp = buildSdp({ ip, port: this.rtpPort, payloadTypes: pts });
|
||||
|
||||
const inviteAuth = SipMessage.createRequest('INVITE', destUri, {
|
||||
via: { host: ip, port: this.config.lanPort },
|
||||
from: { uri: aor, tag: fromTag },
|
||||
to: { uri: destUri },
|
||||
callId: this.dialog!.callId,
|
||||
cseq: 2,
|
||||
contact: `<sip:${ip}:${this.config.lanPort}>`,
|
||||
body: sdp,
|
||||
contentType: 'application/sdp',
|
||||
extraHeaders: [['Proxy-Authorization', authValue]],
|
||||
});
|
||||
|
||||
this.invite = inviteAuth;
|
||||
this.dialog!.localCSeq = 2;
|
||||
|
||||
this.config.log(`[sip-leg:${this.id}] resending INVITE with auth`);
|
||||
this.config.sendSip(inviteAuth.serialize(), this.config.sipTarget);
|
||||
}
|
||||
|
||||
private handleRequest(msg: SipMessage, rinfo: IEndpoint): void {
|
||||
const method = msg.method;
|
||||
this.config.log(`[sip-leg:${this.id}] <- ${method} from ${rinfo.address}:${rinfo.port}`);
|
||||
|
||||
if (method === 'BYE') {
|
||||
// Send 200 OK to the BYE.
|
||||
const ok = SipMessage.createResponse(200, 'OK', msg);
|
||||
this.config.sendSip(ok.serialize(), { address: rinfo.address, port: rinfo.port });
|
||||
|
||||
this.state = 'terminated';
|
||||
if (this.dialog) this.dialog.terminate();
|
||||
this.onTerminated?.(this);
|
||||
this.onStateChange?.(this);
|
||||
}
|
||||
// Other in-dialog requests (re-INVITE, INFO, etc.) can be handled here in the future.
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Send BYE / CANCEL
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
/** Send BYE (if confirmed) or CANCEL (if early) to tear down this leg. */
|
||||
sendHangup(): void {
|
||||
if (!this.dialog) return;
|
||||
|
||||
if (this.dialog.state === 'confirmed') {
|
||||
const bye = this.dialog.createRequest('BYE');
|
||||
this.config.sendSip(bye.serialize(), this.config.sipTarget);
|
||||
this.config.log(`[sip-leg:${this.id}] BYE sent`);
|
||||
} else if (this.dialog.state === 'early' && this.invite) {
|
||||
const cancel = this.dialog.createCancel(this.invite);
|
||||
this.config.sendSip(cancel.serialize(), this.config.sipTarget);
|
||||
this.config.log(`[sip-leg:${this.id}] CANCEL sent`);
|
||||
}
|
||||
|
||||
this.state = 'terminating';
|
||||
this.dialog.terminate();
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// RTP
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
sendRtp(data: Buffer): void {
|
||||
if (!this.remoteMedia) return;
|
||||
this.rtpSock.send(data, this.remoteMedia.port, this.remoteMedia.address);
|
||||
this.pktSent++;
|
||||
}
|
||||
|
||||
/** Send a 1-byte UDP packet to punch NAT hole. */
|
||||
private primeRtp(peer: IEndpoint): void {
|
||||
try {
|
||||
this.rtpSock.send(Buffer.alloc(1), peer.port, peer.address);
|
||||
this.config.log(`[sip-leg:${this.id}] RTP primed -> ${peer.address}:${peer.port}`);
|
||||
} catch (e: any) {
|
||||
this.config.log(`[sip-leg:${this.id}] prime error: ${e.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Silence stream (provider quirks)
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
private startSilence(): void {
|
||||
if (this.silenceTimer) return;
|
||||
const quirks = this.config.provider?.quirks;
|
||||
if (!quirks?.earlyMediaSilence) return;
|
||||
if (!this.remoteMedia) return;
|
||||
|
||||
const PT = quirks.silencePayloadType ?? 9;
|
||||
const MAX = quirks.silenceMaxPackets ?? 250;
|
||||
const PAYLOAD = 160;
|
||||
let seq = Math.floor(Math.random() * 0xffff);
|
||||
let rtpTs = Math.floor(Math.random() * 0xffffffff);
|
||||
let count = 0;
|
||||
|
||||
// Use proper silence byte for the codec (0x00 is NOT silence for most codecs).
|
||||
const silenceByte = silenceByteForPT(PT);
|
||||
|
||||
this.silenceTimer = setInterval(() => {
|
||||
if (this.pktReceived > 0 || count >= MAX) {
|
||||
clearInterval(this.silenceTimer!);
|
||||
this.silenceTimer = null;
|
||||
this.config.log(`[sip-leg:${this.id}] silence stop after ${count} pkts`);
|
||||
return;
|
||||
}
|
||||
const pkt = Buffer.alloc(12 + PAYLOAD, silenceByte);
|
||||
// RTP header (first 12 bytes).
|
||||
pkt[0] = 0x80;
|
||||
pkt[1] = PT;
|
||||
pkt.writeUInt16BE(seq & 0xffff, 2);
|
||||
pkt.writeUInt32BE(rtpTs >>> 0, 4);
|
||||
pkt.writeUInt32BE(this.ssrc >>> 0, 8); // stable SSRC
|
||||
this.rtpSock.send(pkt, this.remoteMedia!.port, this.remoteMedia!.address);
|
||||
seq++;
|
||||
rtpTs += PAYLOAD;
|
||||
count++;
|
||||
}, 20);
|
||||
|
||||
this.config.log(`[sip-leg:${this.id}] silence start -> ${this.remoteMedia.address}:${this.remoteMedia.port} (ssrc=${this.ssrc})`);
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Lifecycle
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
teardown(): void {
|
||||
if (this.silenceTimer) {
|
||||
clearInterval(this.silenceTimer);
|
||||
this.silenceTimer = null;
|
||||
}
|
||||
this.state = 'terminated';
|
||||
if (this.dialog) this.dialog.terminate();
|
||||
// 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.transcoder !== null,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helper: proper silence byte per codec
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** Return the byte value representing digital silence for a given RTP payload type. */
|
||||
function silenceByteForPT(pt: number): number {
|
||||
switch (pt) {
|
||||
case 0: return 0xFF; // PCMU: μ-law silence (zero amplitude)
|
||||
case 8: return 0xD5; // PCMA: A-law silence (zero amplitude)
|
||||
case 9: return 0xD5; // G.722: sub-band silence (zero amplitude)
|
||||
default: return 0xFF; // safe default
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helper: ACK for non-2xx (same transaction)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function buildNon2xxAck(originalInvite: SipMessage, response: SipMessage): SipMessage {
|
||||
const via = originalInvite.getHeader('Via') || '';
|
||||
const from = originalInvite.getHeader('From') || '';
|
||||
const toFromResponse = response.getHeader('To') || '';
|
||||
const callId = originalInvite.callId;
|
||||
const cseqNum = parseInt((originalInvite.getHeader('CSeq') || '1').split(/\s+/)[0], 10);
|
||||
|
||||
return new SipMessage(
|
||||
`ACK ${originalInvite.requestUri} SIP/2.0`,
|
||||
[
|
||||
['Via', via],
|
||||
['From', from],
|
||||
['To', toFromResponse],
|
||||
['Call-ID', callId],
|
||||
['CSeq', `${cseqNum} ACK`],
|
||||
['Max-Forwards', '70'],
|
||||
['Content-Length', '0'],
|
||||
],
|
||||
'',
|
||||
);
|
||||
}
|
||||
68
ts/call/types.ts
Normal file
68
ts/call/types.ts
Normal file
@@ -0,0 +1,68 @@
|
||||
/**
|
||||
* Hub model type definitions — Call, Leg, and status types.
|
||||
*/
|
||||
|
||||
import type { IEndpoint } from '../sip/index.ts';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// State types
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export type TCallState =
|
||||
| 'setting-up'
|
||||
| 'ringing'
|
||||
| 'connected'
|
||||
| 'on-hold'
|
||||
| 'transferring'
|
||||
| 'terminating'
|
||||
| 'terminated';
|
||||
|
||||
export type TLegState =
|
||||
| 'inviting'
|
||||
| 'ringing'
|
||||
| 'connected'
|
||||
| 'on-hold'
|
||||
| 'terminating'
|
||||
| 'terminated';
|
||||
|
||||
export type TLegType = 'sip-device' | 'sip-provider' | 'webrtc';
|
||||
|
||||
export type TCallDirection = 'inbound' | 'outbound' | 'internal';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Status interfaces (for frontend dashboard)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface ILegStatus {
|
||||
id: string;
|
||||
type: TLegType;
|
||||
state: TLegState;
|
||||
remoteMedia: IEndpoint | null;
|
||||
rtpPort: number | null;
|
||||
pktSent: number;
|
||||
pktReceived: number;
|
||||
codec: string | null;
|
||||
transcoding: boolean;
|
||||
}
|
||||
|
||||
export interface ICallStatus {
|
||||
id: string;
|
||||
state: TCallState;
|
||||
direction: TCallDirection;
|
||||
callerNumber: string | null;
|
||||
calleeNumber: string | null;
|
||||
providerUsed: string | null;
|
||||
createdAt: number;
|
||||
duration: number;
|
||||
legs: ILegStatus[];
|
||||
}
|
||||
|
||||
export interface ICallHistoryEntry {
|
||||
id: string;
|
||||
direction: TCallDirection;
|
||||
callerNumber: string | null;
|
||||
calleeNumber: string | null;
|
||||
providerUsed: string | null;
|
||||
startedAt: number;
|
||||
duration: number;
|
||||
}
|
||||
417
ts/call/webrtc-leg.ts
Normal file
417
ts/call/webrtc-leg.ts
Normal file
@@ -0,0 +1,417 @@
|
||||
/**
|
||||
* 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 */ }
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user