feat(proxy-engine): add Rust-based outbound calling, WebRTC bridging, and voicemail handling

This commit is contained in:
2026-04-10 11:36:18 +00:00
parent ad253f823f
commit 239e2ac81d
42 changed files with 3360 additions and 6444 deletions

View File

@@ -1,323 +0,0 @@
/**
* Audio recorder — captures RTP packets from a single direction,
* decodes them to PCM, and writes a WAV file.
*
* Uses the Rust codec bridge to transcode incoming audio (G.722, Opus,
* PCMU, PCMA) to PCMU, then decodes mu-law to 16-bit PCM in TypeScript.
* Output: 8kHz 16-bit mono WAV (standard telephony quality).
*
* Supports:
* - Max recording duration limit
* - Silence detection (stop after N seconds of silence)
* - Manual stop
* - DTMF packets (PT 101) are automatically skipped
*/
import { Buffer } from 'node:buffer';
import fs from 'node:fs';
import path from 'node:path';
import { WavWriter } from './wav-writer.ts';
import type { IWavWriterResult } from './wav-writer.ts';
import { transcode, createSession, destroySession } from '../opusbridge.ts';
// ---------------------------------------------------------------------------
// Types
// ---------------------------------------------------------------------------
export interface IRecordingOptions {
/** Output directory for WAV files. */
outputDir: string;
/** Target sample rate for the WAV output (default 8000). */
sampleRate?: number;
/** Maximum recording duration in seconds. 0 = unlimited. Default 120. */
maxDurationSec?: number;
/** Stop after this many consecutive seconds of silence. 0 = disabled. Default 5. */
silenceTimeoutSec?: number;
/** Silence threshold: max PCM amplitude below this is "silent". Default 200. */
silenceThreshold?: number;
/** Logging function. */
log: (msg: string) => void;
}
export interface IRecordingResult {
/** Full path to the WAV file. */
filePath: string;
/** Duration in milliseconds. */
durationMs: number;
/** Sample rate of the WAV. */
sampleRate: number;
/** Size of the WAV file in bytes. */
fileSize: number;
/** Why the recording was stopped. */
stopReason: TRecordingStopReason;
}
export type TRecordingStopReason = 'manual' | 'max-duration' | 'silence' | 'cancelled';
// ---------------------------------------------------------------------------
// Mu-law decode table (ITU-T G.711)
// ---------------------------------------------------------------------------
/** Pre-computed mu-law → 16-bit linear PCM lookup table (256 entries). */
const MULAW_DECODE: Int16Array = buildMulawDecodeTable();
function buildMulawDecodeTable(): Int16Array {
const table = new Int16Array(256);
for (let i = 0; i < 256; i++) {
// Invert all bits per mu-law standard.
let mu = ~i & 0xff;
const sign = mu & 0x80;
const exponent = (mu >> 4) & 0x07;
const mantissa = mu & 0x0f;
let magnitude = ((mantissa << 1) + 33) << (exponent + 2);
magnitude -= 0x84; // Bias adjustment
table[i] = sign ? -magnitude : magnitude;
}
return table;
}
/** Decode a PCMU payload to 16-bit LE PCM. */
function decodeMulaw(mulaw: Buffer): Buffer {
const pcm = Buffer.alloc(mulaw.length * 2);
for (let i = 0; i < mulaw.length; i++) {
pcm.writeInt16LE(MULAW_DECODE[mulaw[i]], i * 2);
}
return pcm;
}
// ---------------------------------------------------------------------------
// AudioRecorder
// ---------------------------------------------------------------------------
export class AudioRecorder {
/** Current state. */
state: 'idle' | 'recording' | 'stopped' = 'idle';
/** Called when recording stops automatically (silence or max duration). */
onStopped: ((result: IRecordingResult) => void) | null = null;
private outputDir: string;
private sampleRate: number;
private maxDurationSec: number;
private silenceTimeoutSec: number;
private silenceThreshold: number;
private log: (msg: string) => void;
private wavWriter: WavWriter | null = null;
private filePath: string = '';
private codecSessionId: string | null = null;
private stopReason: TRecordingStopReason = 'manual';
// Silence detection.
private consecutiveSilentFrames = 0;
/** Number of 20ms frames that constitute silence timeout. */
private silenceFrameThreshold = 0;
// Max duration timer.
private maxDurationTimer: ReturnType<typeof setTimeout> | null = null;
// Processing queue to avoid concurrent transcodes.
private processQueue: Promise<void> = Promise.resolve();
constructor(options: IRecordingOptions) {
this.outputDir = options.outputDir;
this.sampleRate = options.sampleRate ?? 8000;
this.maxDurationSec = options.maxDurationSec ?? 120;
this.silenceTimeoutSec = options.silenceTimeoutSec ?? 5;
this.silenceThreshold = options.silenceThreshold ?? 200;
this.log = options.log;
}
/**
* Start recording. Creates the output directory, WAV file, and codec session.
* @param fileId - unique ID for the recording file name
*/
async start(fileId?: string): Promise<void> {
if (this.state !== 'idle') return;
// Ensure output directory exists.
if (!fs.existsSync(this.outputDir)) {
fs.mkdirSync(this.outputDir, { recursive: true });
}
// Generate file path.
const id = fileId ?? `rec-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
this.filePath = path.join(this.outputDir, `${id}.wav`);
// Create a codec session for isolated decoding.
this.codecSessionId = `recorder-${id}`;
await createSession(this.codecSessionId);
// Open WAV writer.
this.wavWriter = new WavWriter({
filePath: this.filePath,
sampleRate: this.sampleRate,
});
this.wavWriter.open();
// Silence detection threshold: frames in timeout period.
this.silenceFrameThreshold = this.silenceTimeoutSec > 0
? Math.ceil((this.silenceTimeoutSec * 1000) / 20)
: 0;
this.consecutiveSilentFrames = 0;
// Max duration timer.
if (this.maxDurationSec > 0) {
this.maxDurationTimer = setTimeout(() => {
if (this.state === 'recording') {
this.stopReason = 'max-duration';
this.log(`[recorder] max duration reached (${this.maxDurationSec}s)`);
this.stop().then((result) => this.onStopped?.(result));
}
}, this.maxDurationSec * 1000);
}
this.state = 'recording';
this.stopReason = 'manual';
this.log(`[recorder] started → ${this.filePath}`);
}
/**
* Feed an RTP packet. Strips the 12-byte header, transcodes the payload
* to PCMU via the Rust bridge, decodes to PCM, and writes to WAV.
* Skips telephone-event (DTMF) and comfort noise packets.
*/
processRtp(data: Buffer): void {
if (this.state !== 'recording') return;
if (data.length < 13) return; // too short
const pt = data[1] & 0x7f;
// Skip DTMF (telephone-event) and comfort noise.
if (pt === 101 || pt === 13) return;
const payload = data.subarray(12);
if (payload.length === 0) return;
// Queue processing to avoid concurrent transcodes corrupting codec state.
this.processQueue = this.processQueue.then(() => this.decodeAndWrite(payload, pt));
}
/** Decode a single RTP payload to PCM and write to WAV. */
private async decodeAndWrite(payload: Buffer, pt: number): Promise<void> {
if (this.state !== 'recording' || !this.wavWriter) return;
let pcm: Buffer;
if (pt === 0) {
// PCMU: decode directly in TypeScript (no Rust round-trip needed).
pcm = decodeMulaw(payload);
} else {
// All other codecs: transcode to PCMU via Rust, then decode mu-law.
const mulaw = await transcode(payload, pt, 0, this.codecSessionId ?? undefined);
if (!mulaw) return;
pcm = decodeMulaw(mulaw);
}
// Silence detection.
if (this.silenceFrameThreshold > 0) {
if (isSilent(pcm, this.silenceThreshold)) {
this.consecutiveSilentFrames++;
if (this.consecutiveSilentFrames >= this.silenceFrameThreshold) {
this.stopReason = 'silence';
this.log(`[recorder] silence detected (${this.silenceTimeoutSec}s)`);
this.stop().then((result) => this.onStopped?.(result));
return;
}
} else {
this.consecutiveSilentFrames = 0;
}
}
this.wavWriter.write(pcm);
}
/**
* Stop recording and finalize the WAV file.
*/
async stop(): Promise<IRecordingResult> {
if (this.state === 'stopped' || this.state === 'idle') {
return {
filePath: this.filePath,
durationMs: 0,
sampleRate: this.sampleRate,
fileSize: 0,
stopReason: this.stopReason,
};
}
this.state = 'stopped';
// Wait for pending decode operations to finish.
await this.processQueue;
// Clear timers.
if (this.maxDurationTimer) {
clearTimeout(this.maxDurationTimer);
this.maxDurationTimer = null;
}
// Finalize WAV.
let wavResult: IWavWriterResult | null = null;
if (this.wavWriter) {
wavResult = this.wavWriter.close();
this.wavWriter = null;
}
// Destroy codec session.
if (this.codecSessionId) {
await destroySession(this.codecSessionId);
this.codecSessionId = null;
}
const result: IRecordingResult = {
filePath: this.filePath,
durationMs: wavResult?.durationMs ?? 0,
sampleRate: this.sampleRate,
fileSize: wavResult?.fileSize ?? 0,
stopReason: this.stopReason,
};
this.log(`[recorder] stopped (${result.stopReason}): ${result.durationMs}ms → ${this.filePath}`);
return result;
}
/** Cancel recording — stops and deletes the WAV file. */
async cancel(): Promise<void> {
this.stopReason = 'cancelled';
await this.stop();
// Delete the incomplete file.
try {
if (fs.existsSync(this.filePath)) {
fs.unlinkSync(this.filePath);
this.log(`[recorder] cancelled — deleted ${this.filePath}`);
}
} catch { /* best effort */ }
}
/** Clean up all resources. */
destroy(): void {
if (this.state === 'recording') {
this.cancel();
}
this.onStopped = null;
}
}
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
/** Check if a PCM buffer is "silent" (max amplitude below threshold). */
function isSilent(pcm: Buffer, threshold: number): boolean {
let maxAmp = 0;
for (let i = 0; i < pcm.length - 1; i += 2) {
const sample = pcm.readInt16LE(i);
const abs = sample < 0 ? -sample : sample;
if (abs > maxAmp) maxAmp = abs;
// Early exit: already above threshold.
if (maxAmp >= threshold) return false;
}
return true;
}

File diff suppressed because it is too large Load Diff

View File

@@ -1,265 +0,0 @@
/**
* 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) {
// If a system leg is connected, report voicemail/ivr state for the dashboard.
const systemLeg = legs.find((l) => l.type === 'system');
if (systemLeg) {
// Keep voicemail/ivr state if already set; otherwise set connected.
if (this.state !== 'voicemail' && this.state !== 'ivr') {
this.state = 'connected';
}
} else {
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 (system legs have no SIP signaling).
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) {
// Send BYE/CANCEL for SIP legs (system legs just get torn down).
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()),
};
}
}

View File

@@ -1,272 +0,0 @@
/**
* DTMF detection — parses RFC 2833 telephone-event RTP packets
* and SIP INFO (application/dtmf-relay) messages.
*
* Designed to be attached to any leg or RTP stream. The detector
* deduplicates repeated telephone-event packets (same digit is sent
* multiple times with increasing duration) and fires a callback
* once per detected digit.
*/
import { Buffer } from 'node:buffer';
import type { SipMessage } from '../sip/index.ts';
// ---------------------------------------------------------------------------
// Types
// ---------------------------------------------------------------------------
/** A single detected DTMF digit. */
export interface IDtmfDigit {
/** The digit character: '0'-'9', '*', '#', 'A'-'D'. */
digit: string;
/** Duration in milliseconds. */
durationMs: number;
/** Detection source. */
source: 'rfc2833' | 'sip-info';
/** Wall-clock timestamp when the digit was detected. */
timestamp: number;
}
/** Callback fired once per detected DTMF digit. */
export type TDtmfCallback = (digit: IDtmfDigit) => void;
// ---------------------------------------------------------------------------
// Constants
// ---------------------------------------------------------------------------
/** RFC 2833 event ID → character mapping. */
const EVENT_CHARS: string[] = [
'0', '1', '2', '3', '4', '5', '6', '7', '8', '9',
'*', '#', 'A', 'B', 'C', 'D',
];
/** Safety timeout: report digit if no End packet arrives within this many ms. */
const SAFETY_TIMEOUT_MS = 200;
// ---------------------------------------------------------------------------
// DtmfDetector
// ---------------------------------------------------------------------------
/**
* Detects DTMF digits from RFC 2833 RTP packets and SIP INFO messages.
*
* Usage:
* ```
* const detector = new DtmfDetector(log);
* detector.onDigit = (d) => console.log('DTMF:', d.digit);
* // Feed every RTP packet (detector checks PT internally):
* detector.processRtp(rtpPacket);
* // Or feed a SIP INFO message:
* detector.processSipInfo(sipMsg);
* ```
*/
export class DtmfDetector {
/** Callback fired once per detected digit. */
onDigit: TDtmfCallback | null = null;
/** Negotiated telephone-event payload type (default 101). */
private telephoneEventPt: number;
/** Clock rate for duration calculation (default 8000 Hz). */
private clockRate: number;
// -- Deduplication state for RFC 2833 --
/** Event ID of the digit currently being received. */
private currentEventId: number | null = null;
/** RTP timestamp of the first packet for the current event. */
private currentEventTs: number | null = null;
/** Whether the current event has already been reported. */
private currentEventReported = false;
/** Latest duration value seen (in clock ticks). */
private currentEventDuration = 0;
/** Latest volume value seen (dBm0, 0 = loudest). */
private currentEventVolume = 0;
/** Safety timer: fires if no End packet arrives. */
private safetyTimer: ReturnType<typeof setTimeout> | null = null;
private log: (msg: string) => void;
constructor(
log: (msg: string) => void,
telephoneEventPt = 101,
clockRate = 8000,
) {
this.log = log;
this.telephoneEventPt = telephoneEventPt;
this.clockRate = clockRate;
}
// -------------------------------------------------------------------------
// RFC 2833 RTP processing
// -------------------------------------------------------------------------
/**
* Feed an RTP packet. Checks PT; ignores non-DTMF packets.
* Expects the full RTP packet (12-byte header + payload).
*/
processRtp(data: Buffer): void {
if (data.length < 16) return; // 12-byte header + 4-byte telephone-event payload minimum
const pt = data[1] & 0x7f;
if (pt !== this.telephoneEventPt) return;
// Parse RTP header fields we need.
const marker = (data[1] & 0x80) !== 0;
const rtpTimestamp = data.readUInt32BE(4);
// Parse telephone-event payload (4 bytes starting at offset 12).
const eventId = data[12];
const endBit = (data[13] & 0x80) !== 0;
const volume = data[13] & 0x3f;
const duration = data.readUInt16BE(14);
// Validate event ID.
if (eventId >= EVENT_CHARS.length) return;
// Detect new event: marker bit, different event ID, or different RTP timestamp.
const isNewEvent =
marker ||
eventId !== this.currentEventId ||
rtpTimestamp !== this.currentEventTs;
if (isNewEvent) {
// If there was an unreported previous event, report it now (fallback).
this.reportPendingEvent();
// Start tracking the new event.
this.currentEventId = eventId;
this.currentEventTs = rtpTimestamp;
this.currentEventReported = false;
this.currentEventDuration = duration;
this.currentEventVolume = volume;
// Start safety timer.
this.clearSafetyTimer();
this.safetyTimer = setTimeout(() => {
this.reportPendingEvent();
}, SAFETY_TIMEOUT_MS);
}
// Update duration (it increases with each retransmission).
if (duration > this.currentEventDuration) {
this.currentEventDuration = duration;
}
// Report on End bit (first time only).
if (endBit && !this.currentEventReported) {
this.currentEventReported = true;
this.clearSafetyTimer();
const digit = EVENT_CHARS[eventId];
const durationMs = (this.currentEventDuration / this.clockRate) * 1000;
this.log(`[dtmf] RFC 2833 digit '${digit}' (${Math.round(durationMs)}ms)`);
this.onDigit?.({
digit,
durationMs,
source: 'rfc2833',
timestamp: Date.now(),
});
}
}
/** Report a pending (unreported) event — called by safety timer or on new event start. */
private reportPendingEvent(): void {
if (
this.currentEventId !== null &&
!this.currentEventReported &&
this.currentEventId < EVENT_CHARS.length
) {
this.currentEventReported = true;
this.clearSafetyTimer();
const digit = EVENT_CHARS[this.currentEventId];
const durationMs = (this.currentEventDuration / this.clockRate) * 1000;
this.log(`[dtmf] RFC 2833 digit '${digit}' (${Math.round(durationMs)}ms, safety timeout)`);
this.onDigit?.({
digit,
durationMs,
source: 'rfc2833',
timestamp: Date.now(),
});
}
}
private clearSafetyTimer(): void {
if (this.safetyTimer) {
clearTimeout(this.safetyTimer);
this.safetyTimer = null;
}
}
// -------------------------------------------------------------------------
// SIP INFO processing
// -------------------------------------------------------------------------
/**
* Parse a SIP INFO message carrying DTMF.
* Supports Content-Type: application/dtmf-relay (Signal=X / Duration=Y).
*/
processSipInfo(msg: SipMessage): void {
const ct = (msg.getHeader('Content-Type') || '').toLowerCase();
if (!ct.includes('application/dtmf-relay') && !ct.includes('application/dtmf')) return;
const body = msg.body || '';
if (ct.includes('application/dtmf-relay')) {
// Format: "Signal= 5\r\nDuration= 160\r\n"
const signalMatch = body.match(/Signal\s*=\s*(\S+)/i);
const durationMatch = body.match(/Duration\s*=\s*(\d+)/i);
if (!signalMatch) return;
const signal = signalMatch[1];
const durationTicks = durationMatch ? parseInt(durationMatch[1], 10) : 160;
// Validate digit.
if (signal.length !== 1 || !/[0-9*#A-Da-d]/.test(signal)) return;
const digit = signal.toUpperCase();
const durationMs = (durationTicks / this.clockRate) * 1000;
this.log(`[dtmf] SIP INFO digit '${digit}' (${Math.round(durationMs)}ms)`);
this.onDigit?.({
digit,
durationMs,
source: 'sip-info',
timestamp: Date.now(),
});
} else if (ct.includes('application/dtmf')) {
// Simple format: just the digit character in the body.
const digit = body.trim().toUpperCase();
if (digit.length !== 1 || !/[0-9*#A-D]/.test(digit)) return;
this.log(`[dtmf] SIP INFO digit '${digit}' (application/dtmf)`);
this.onDigit?.({
digit,
durationMs: 250, // default duration
source: 'sip-info',
timestamp: Date.now(),
});
}
}
// -------------------------------------------------------------------------
// Lifecycle
// -------------------------------------------------------------------------
/** Reset detection state (e.g., between calls). */
reset(): void {
this.currentEventId = null;
this.currentEventTs = null;
this.currentEventReported = false;
this.currentEventDuration = 0;
this.currentEventVolume = 0;
this.clearSafetyTimer();
}
/** Clean up timers and references. */
destroy(): void {
this.clearSafetyTimer();
this.onDigit = null;
}
}

View File

@@ -1,12 +0,0 @@
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';

View File

@@ -1,104 +0,0 @@
/**
* 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}`;
}
}

View File

@@ -17,9 +17,26 @@ import { execSync } from 'node:child_process';
import fs from 'node:fs';
import path from 'node:path';
import { Buffer } from 'node:buffer';
import { buildRtpHeader, rtpClockIncrement } from './leg.ts';
import { encodePcm, isCodecReady } from '../opusbridge.ts';
/** RTP clock increment per 20ms frame for each codec. */
function rtpClockIncrement(pt: number): number {
if (pt === 111) return 960;
if (pt === 9) return 160;
return 160;
}
/** Build a fresh RTP header. */
function buildRtpHeader(pt: number, seq: number, ts: number, ssrc: number, marker: boolean): Buffer {
const hdr = Buffer.alloc(12);
hdr[0] = 0x80;
hdr[1] = (marker ? 0x80 : 0) | (pt & 0x7f);
hdr.writeUInt16BE(seq & 0xffff, 2);
hdr.writeUInt32BE(ts >>> 0, 4);
hdr.writeUInt32BE(ssrc >>> 0, 8);
return hdr;
}
// ---------------------------------------------------------------------------
// Types
// ---------------------------------------------------------------------------

View File

@@ -1,71 +0,0 @@
/**
* 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);
}
}

View File

@@ -1,633 +0,0 @@
/**
* 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;
/** Callback for SIP INFO messages (used for DTMF relay). */
onInfoReceived: ((msg: SipMessage) => 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);
}
if (method === 'INFO') {
// Respond 200 OK to the INFO request.
const ok = SipMessage.createResponse(200, 'OK', msg);
this.config.sendSip(ok.serialize(), { address: rinfo.address, port: rinfo.port });
// Forward to DTMF handler (if attached).
this.onInfoReceived?.(msg);
}
// Other in-dialog requests (re-INVITE, 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'],
],
'',
);
}

View File

@@ -1,336 +0,0 @@
/**
* SystemLeg — virtual ILeg for IVR menus and voicemail.
*
* Plugs into the Call hub exactly like SipLeg or WebRtcLeg:
* - Receives caller audio via sendRtp() (called by Call.forwardRtp)
* - Plays prompts by firing onRtpReceived (picked up by Call.forwardRtp → caller's leg)
* - Detects DTMF from caller's audio (RFC 2833 telephone-event)
* - Records caller's audio to WAV files (for voicemail)
*
* No UDP socket or SIP dialog needed — purely virtual.
*/
import { Buffer } from 'node:buffer';
import type dgram from 'node:dgram';
import type { IEndpoint } from '../sip/index.ts';
import type { SipMessage } from '../sip/index.ts';
import type { SipDialog } from '../sip/index.ts';
import type { IRtpTranscoder } from '../codec.ts';
import type { ILeg } from './leg.ts';
import type { TLegState, TLegType, ILegStatus } from './types.ts';
import { DtmfDetector } from './dtmf-detector.ts';
import type { IDtmfDigit } from './dtmf-detector.ts';
import { AudioRecorder } from './audio-recorder.ts';
import type { IRecordingResult } from './audio-recorder.ts';
import { PromptCache, playPromptG722, playPromptOpus } from './prompt-cache.ts';
import type { ICachedPrompt } from './prompt-cache.ts';
import { buildRtpHeader, rtpClockIncrement } from './leg.ts';
// ---------------------------------------------------------------------------
// Types
// ---------------------------------------------------------------------------
export type TSystemLegMode = 'ivr' | 'voicemail-greeting' | 'voicemail-recording' | 'idle';
export interface ISystemLegConfig {
/** Logging function. */
log: (msg: string) => void;
/** The prompt cache for TTS playback. */
promptCache: PromptCache;
/**
* Codec payload type used by the caller's leg.
* Determines whether G.722 (9) or Opus (111) frames are played.
* Default: 9 (G.722, typical for SIP callers).
*/
callerCodecPt?: number;
/** Called when a DTMF digit is detected. */
onDtmfDigit?: (digit: IDtmfDigit) => void;
/** Called when a voicemail recording is complete. */
onRecordingComplete?: (result: IRecordingResult) => void;
/** Called when the SystemLeg wants to signal an IVR action. */
onAction?: (action: string, data?: any) => void;
}
// ---------------------------------------------------------------------------
// SystemLeg
// ---------------------------------------------------------------------------
export class SystemLeg implements ILeg {
readonly id: string;
readonly type: TLegType = 'system';
state: TLegState = 'connected'; // Immediately "connected" — no setup phase.
/** Current operating mode. */
mode: TSystemLegMode = 'idle';
// --- ILeg required fields (virtual — no real network resources) ---
readonly sipCallId: string;
readonly rtpPort: number | null = null;
readonly rtpSock: dgram.Socket | null = null;
remoteMedia: IEndpoint | null = null;
codec: number | null = null;
transcoder: IRtpTranscoder | null = null;
pktSent = 0;
pktReceived = 0;
readonly dialog: SipDialog | null = null;
/**
* Set by Call.addLeg() — firing this injects audio into the Call hub,
* which forwards it to the caller's leg.
*/
onRtpReceived: ((data: Buffer) => void) | null = null;
// --- Internal components ---
private dtmfDetector: DtmfDetector;
private recorder: AudioRecorder | null = null;
private promptCache: PromptCache;
private promptCancel: (() => void) | null = null;
private callerCodecPt: number;
private log: (msg: string) => void;
readonly config: ISystemLegConfig;
/** Stable SSRC for all prompt playback (random, stays constant for the leg's lifetime). */
private ssrc: number;
/** Sequence/timestamp counters for Opus prompt playback (shared for seamless transitions). */
private opusCounters = { seq: 0, ts: 0 };
constructor(id: string, config: ISystemLegConfig) {
this.id = id;
this.sipCallId = `system-${id}`; // Virtual Call-ID — not a real SIP dialog.
this.config = config;
this.log = config.log;
this.promptCache = config.promptCache;
this.callerCodecPt = config.callerCodecPt ?? 9; // Default G.722
this.ssrc = (Math.random() * 0xffffffff) >>> 0;
this.opusCounters.seq = Math.floor(Math.random() * 0xffff);
this.opusCounters.ts = Math.floor(Math.random() * 0xffffffff);
// Initialize DTMF detector.
this.dtmfDetector = new DtmfDetector(this.log);
this.dtmfDetector.onDigit = (digit) => {
this.log(`[system-leg:${this.id}] DTMF '${digit.digit}' (${digit.source})`);
this.config.onDtmfDigit?.(digit);
};
}
// -------------------------------------------------------------------------
// ILeg: sendRtp — receives caller's audio from the Call hub
// -------------------------------------------------------------------------
/**
* Called by the Call hub (via forwardRtp) to deliver the caller's audio
* to this leg. We use this for DTMF detection and recording.
*/
sendRtp(data: Buffer): void {
this.pktReceived++;
// Feed DTMF detector (it checks PT internally, ignores non-101 packets).
this.dtmfDetector.processRtp(data);
// Feed recorder if active.
if (this.mode === 'voicemail-recording' && this.recorder) {
this.recorder.processRtp(data);
}
}
// -------------------------------------------------------------------------
// ILeg: handleSipMessage — handles SIP INFO for DTMF
// -------------------------------------------------------------------------
/**
* Handle a SIP message routed to this leg. Only SIP INFO (DTMF) is relevant.
*/
handleSipMessage(msg: SipMessage, _rinfo: IEndpoint): void {
if (msg.method === 'INFO') {
this.dtmfDetector.processSipInfo(msg);
}
}
// -------------------------------------------------------------------------
// Prompt playback
// -------------------------------------------------------------------------
/**
* Play a cached prompt by ID.
* The audio is injected into the Call hub via onRtpReceived.
*
* @param promptId - ID of the prompt in the PromptCache
* @param onDone - called when playback completes (not on cancel)
* @returns true if playback started, false if prompt not found
*/
playPrompt(promptId: string, onDone?: () => void): boolean {
const prompt = this.promptCache.get(promptId);
if (!prompt) {
this.log(`[system-leg:${this.id}] prompt "${promptId}" not found`);
onDone?.();
return false;
}
// Cancel any in-progress playback.
this.cancelPrompt();
this.log(`[system-leg:${this.id}] playing prompt "${promptId}" (${prompt.durationMs}ms)`);
// Select G.722 or Opus frames based on caller codec.
if (this.callerCodecPt === 111) {
// WebRTC caller: play Opus frames.
this.promptCancel = playPromptOpus(
prompt,
(pkt) => this.injectPacket(pkt),
this.ssrc,
this.opusCounters,
() => {
this.promptCancel = null;
onDone?.();
},
);
} else {
// SIP caller: play G.722 frames (works for all SIP codecs since the
// SipLeg's RTP socket sends whatever we give it — the provider's
// media endpoint accepts the codec negotiated in the SDP).
this.promptCancel = playPromptG722(
prompt,
(pkt) => this.injectPacket(pkt),
this.ssrc,
() => {
this.promptCancel = null;
onDone?.();
},
);
}
return this.promptCancel !== null;
}
/**
* Play a sequence of prompts, one after another.
*/
playPromptSequence(promptIds: string[], onDone?: () => void): void {
let index = 0;
const playNext = () => {
if (index >= promptIds.length) {
onDone?.();
return;
}
const id = promptIds[index++];
if (!this.playPrompt(id, playNext)) {
// Prompt not found — skip and play next.
playNext();
}
};
playNext();
}
/** Cancel any in-progress prompt playback. */
cancelPrompt(): void {
if (this.promptCancel) {
this.promptCancel();
this.promptCancel = null;
}
}
/** Whether a prompt is currently playing. */
get isPlaying(): boolean {
return this.promptCancel !== null;
}
/**
* Inject an RTP packet into the Call hub.
* This simulates "receiving" audio on this leg — the hub
* will forward it to the caller's leg.
*/
private injectPacket(pkt: Buffer): void {
this.pktSent++;
this.onRtpReceived?.(pkt);
}
// -------------------------------------------------------------------------
// Recording
// -------------------------------------------------------------------------
/**
* Start recording the caller's audio.
* @param outputDir - directory to write the WAV file
* @param fileId - unique ID for the file name
*/
async startRecording(outputDir: string, fileId?: string): Promise<void> {
if (this.recorder) {
await this.recorder.stop();
}
this.recorder = new AudioRecorder({
outputDir,
log: this.log,
maxDurationSec: 120,
silenceTimeoutSec: 5,
});
this.recorder.onStopped = (result) => {
this.log(`[system-leg:${this.id}] recording auto-stopped (${result.stopReason})`);
this.config.onRecordingComplete?.(result);
};
this.mode = 'voicemail-recording';
await this.recorder.start(fileId);
}
/**
* Stop recording and finalize the WAV file.
*/
async stopRecording(): Promise<IRecordingResult | null> {
if (!this.recorder) return null;
const result = await this.recorder.stop();
this.recorder = null;
return result;
}
/** Cancel recording — stops and deletes the file. */
async cancelRecording(): Promise<void> {
if (this.recorder) {
await this.recorder.cancel();
this.recorder = null;
}
}
// -------------------------------------------------------------------------
// Lifecycle
// -------------------------------------------------------------------------
/** Release all resources. */
teardown(): void {
this.cancelPrompt();
// Stop recording gracefully.
if (this.recorder && this.recorder.state === 'recording') {
this.recorder.stop().then((result) => {
this.config.onRecordingComplete?.(result);
});
this.recorder = null;
}
this.dtmfDetector.destroy();
this.state = 'terminated';
this.mode = 'idle';
this.onRtpReceived = null;
this.log(`[system-leg:${this.id}] torn down`);
}
/** Status snapshot for the dashboard. */
getStatus(): ILegStatus {
return {
id: this.id,
type: this.type,
state: this.state,
remoteMedia: null,
rtpPort: null,
pktSent: this.pktSent,
pktReceived: this.pktReceived,
codec: this.callerCodecPt === 111 ? 'Opus' : 'G.722',
transcoding: false,
};
}
}

View File

@@ -1,70 +0,0 @@
/**
* 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'
| 'voicemail'
| 'ivr'
| 'transferring'
| 'terminating'
| 'terminated';
export type TLegState =
| 'inviting'
| 'ringing'
| 'connected'
| 'on-hold'
| 'terminating'
| 'terminated';
export type TLegType = 'sip-device' | 'sip-provider' | 'webrtc' | 'system';
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;
}

View File

@@ -1,163 +0,0 @@
/**
* Streaming WAV file writer — opens a file, writes a placeholder header,
* appends raw PCM data in chunks, and finalizes (patches sizes) on close.
*
* Produces standard RIFF/WAVE format compatible with the WAV parser
* in announcement.ts (extractPcmFromWav).
*/
import fs from 'node:fs';
import { Buffer } from 'node:buffer';
// ---------------------------------------------------------------------------
// Types
// ---------------------------------------------------------------------------
export interface IWavWriterOptions {
/** Full path to the output WAV file. */
filePath: string;
/** Sample rate in Hz (e.g. 16000). */
sampleRate: number;
/** Number of channels (default 1 = mono). */
channels?: number;
/** Bits per sample (default 16). */
bitsPerSample?: number;
}
export interface IWavWriterResult {
/** Full path to the WAV file. */
filePath: string;
/** Total duration in milliseconds. */
durationMs: number;
/** Sample rate of the output file. */
sampleRate: number;
/** Total number of audio samples written. */
totalSamples: number;
/** File size in bytes. */
fileSize: number;
}
// ---------------------------------------------------------------------------
// WAV header constants
// ---------------------------------------------------------------------------
/** Standard WAV header size: RIFF(12) + fmt(24) + data-header(8) = 44 bytes. */
const HEADER_SIZE = 44;
// ---------------------------------------------------------------------------
// WavWriter
// ---------------------------------------------------------------------------
export class WavWriter {
private fd: number | null = null;
private totalDataBytes = 0;
private closed = false;
private filePath: string;
private sampleRate: number;
private channels: number;
private bitsPerSample: number;
constructor(options: IWavWriterOptions) {
this.filePath = options.filePath;
this.sampleRate = options.sampleRate;
this.channels = options.channels ?? 1;
this.bitsPerSample = options.bitsPerSample ?? 16;
}
/** Open the file and write a placeholder 44-byte WAV header. */
open(): void {
if (this.fd !== null) throw new Error('WavWriter already open');
this.fd = fs.openSync(this.filePath, 'w');
this.totalDataBytes = 0;
this.closed = false;
// Write 44 bytes of zeros as placeholder — patched in close().
const placeholder = Buffer.alloc(HEADER_SIZE);
fs.writeSync(this.fd, placeholder, 0, HEADER_SIZE, 0);
}
/** Append raw 16-bit little-endian PCM samples. */
write(pcm: Buffer): void {
if (this.fd === null || this.closed) return;
if (pcm.length === 0) return;
fs.writeSync(this.fd, pcm, 0, pcm.length);
this.totalDataBytes += pcm.length;
}
/**
* Finalize: rewrite the RIFF and data chunk sizes in the header, close the file.
* Returns metadata about the written WAV.
*/
close(): IWavWriterResult {
if (this.fd === null || this.closed) {
return {
filePath: this.filePath,
durationMs: 0,
sampleRate: this.sampleRate,
totalSamples: 0,
fileSize: HEADER_SIZE,
};
}
this.closed = true;
const blockAlign = this.channels * (this.bitsPerSample / 8);
const byteRate = this.sampleRate * blockAlign;
const fileSize = HEADER_SIZE + this.totalDataBytes;
// Build the complete 44-byte header.
const hdr = Buffer.alloc(HEADER_SIZE);
let offset = 0;
// RIFF chunk descriptor.
hdr.write('RIFF', offset); offset += 4;
hdr.writeUInt32LE(fileSize - 8, offset); offset += 4; // ChunkSize = fileSize - 8
hdr.write('WAVE', offset); offset += 4;
// fmt sub-chunk.
hdr.write('fmt ', offset); offset += 4;
hdr.writeUInt32LE(16, offset); offset += 4; // Subchunk1Size (PCM = 16)
hdr.writeUInt16LE(1, offset); offset += 2; // AudioFormat (1 = PCM)
hdr.writeUInt16LE(this.channels, offset); offset += 2;
hdr.writeUInt32LE(this.sampleRate, offset); offset += 4;
hdr.writeUInt32LE(byteRate, offset); offset += 4;
hdr.writeUInt16LE(blockAlign, offset); offset += 2;
hdr.writeUInt16LE(this.bitsPerSample, offset); offset += 2;
// data sub-chunk.
hdr.write('data', offset); offset += 4;
hdr.writeUInt32LE(this.totalDataBytes, offset); offset += 4;
// Patch the header at the beginning of the file.
fs.writeSync(this.fd, hdr, 0, HEADER_SIZE, 0);
fs.closeSync(this.fd);
this.fd = null;
const bytesPerSample = this.bitsPerSample / 8;
const totalSamples = Math.floor(this.totalDataBytes / (bytesPerSample * this.channels));
const durationMs = (totalSamples / this.sampleRate) * 1000;
return {
filePath: this.filePath,
durationMs: Math.round(durationMs),
sampleRate: this.sampleRate,
totalSamples,
fileSize,
};
}
/** Current recording duration in milliseconds. */
get durationMs(): number {
const bytesPerSample = this.bitsPerSample / 8;
const totalSamples = Math.floor(this.totalDataBytes / (bytesPerSample * this.channels));
return (totalSamples / this.sampleRate) * 1000;
}
/** Whether the writer is still open and accepting data. */
get isOpen(): boolean {
return this.fd !== null && !this.closed;
}
}

View File

@@ -1,417 +0,0 @@
/**
* 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 */ }
}
}