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.
623 lines
20 KiB
TypeScript
623 lines
20 KiB
TypeScript
/**
|
|
* 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'],
|
|
],
|
|
'',
|
|
);
|
|
}
|