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

256 lines
7.7 KiB
TypeScript

/**
* Call — the hub entity in the hub model.
*
* A Call owns N legs and bridges their media. For 2-party calls, RTP packets
* from leg A are forwarded to leg B and vice versa. For N>2 party calls,
* packets from each leg are forwarded to all other legs (fan-out).
*
* Transcoding is applied per-leg when codecs differ.
*/
import { Buffer } from 'node:buffer';
import type { ILeg } from './leg.ts';
import type { TCallState, TCallDirection, ICallStatus } from './types.ts';
import { RtpPortPool } from './rtp-port-pool.ts';
import type { SipLeg } from './sip-leg.ts';
export class Call {
readonly id: string;
state: TCallState = 'setting-up';
direction: TCallDirection;
readonly createdAt: number;
callerNumber: string | null = null;
calleeNumber: string | null = null;
providerUsed: string | null = null;
/** All legs in this call. */
private legs = new Map<string, ILeg>();
/** Codec payload type for the "native" audio in the call (usually the first SIP leg's codec). */
private nativeCodec: number | null = null;
/** Port pool reference for cleanup. */
private portPool: RtpPortPool;
private log: (msg: string) => void;
private onChange: ((call: Call) => void) | null = null;
constructor(options: {
id: string;
direction: TCallDirection;
portPool: RtpPortPool;
log: (msg: string) => void;
onChange?: (call: Call) => void;
}) {
this.id = options.id;
this.direction = options.direction;
this.createdAt = Date.now();
this.portPool = options.portPool;
this.log = options.log;
this.onChange = options.onChange ?? null;
}
// -------------------------------------------------------------------------
// Leg management
// -------------------------------------------------------------------------
/** Add a leg to this call and wire up media forwarding. */
addLeg(leg: ILeg): void {
this.legs.set(leg.id, leg);
// Wire up RTP forwarding: when this leg receives a packet, forward to all other legs.
leg.onRtpReceived = (data: Buffer) => {
this.forwardRtp(leg.id, data);
};
this.log(`[call:${this.id}] added leg ${leg.id} (${leg.type}), total=${this.legs.size}`);
this.updateState();
}
/** Remove a leg from this call, tear it down, and release its port. */
removeLeg(legId: string): void {
const leg = this.legs.get(legId);
if (!leg) return;
leg.onRtpReceived = null;
leg.teardown();
if (leg.rtpPort) {
this.portPool.release(leg.rtpPort);
}
this.legs.delete(legId);
this.log(`[call:${this.id}] removed leg ${legId}, total=${this.legs.size}`);
this.updateState();
}
getLeg(legId: string): ILeg | null {
return this.legs.get(legId) ?? null;
}
getLegs(): ILeg[] {
return [...this.legs.values()];
}
getLegByType(type: string): ILeg | null {
for (const leg of this.legs.values()) {
if (leg.type === type) return leg;
}
return null;
}
getLegBySipCallId(sipCallId: string): ILeg | null {
for (const leg of this.legs.values()) {
if (leg.sipCallId === sipCallId) return leg;
}
return null;
}
get legCount(): number {
return this.legs.size;
}
// -------------------------------------------------------------------------
// Media forwarding (the hub)
// -------------------------------------------------------------------------
private forwardRtp(fromLegId: string, data: Buffer): void {
for (const [id, leg] of this.legs) {
if (id === fromLegId) continue;
if (leg.state !== 'connected') continue;
// For WebRTC legs, sendRtp calls forwardToBrowser which handles transcoding internally.
// For SIP legs, forward the raw packet (same codec path) or let the leg handle it.
// The Call hub does NOT transcode — that's the leg's responsibility.
leg.sendRtp(data);
}
}
// -------------------------------------------------------------------------
// State management
// -------------------------------------------------------------------------
private updateState(): void {
if (this.state === 'terminated' || this.state === 'terminating') return;
const legs = [...this.legs.values()];
if (legs.length === 0) {
this.state = 'terminated';
} else if (legs.every((l) => l.state === 'terminated')) {
this.state = 'terminated';
} else if (legs.some((l) => l.state === 'connected') && legs.filter((l) => l.state !== 'terminated').length >= 2) {
this.state = 'connected';
} else if (legs.some((l) => l.state === 'ringing')) {
this.state = 'ringing';
} else {
this.state = 'setting-up';
}
this.onChange?.(this);
}
/** Notify the call that a leg's state has changed. */
notifyLegStateChange(_leg: ILeg): void {
this.updateState();
}
// -------------------------------------------------------------------------
// Hangup
// -------------------------------------------------------------------------
/** Tear down all legs and terminate the call. */
hangup(): void {
if (this.state === 'terminated' || this.state === 'terminating') return;
this.state = 'terminating';
this.log(`[call:${this.id}] hanging up (${this.legs.size} legs)`);
for (const [id, leg] of this.legs) {
// Send BYE/CANCEL for SIP legs.
if (leg.type === 'sip-device' || leg.type === 'sip-provider') {
(leg as SipLeg).sendHangup();
}
leg.teardown();
if (leg.rtpPort) {
this.portPool.release(leg.rtpPort);
}
}
this.legs.clear();
this.state = 'terminated';
this.onChange?.(this);
}
/**
* Handle a BYE from one leg — tear down the other legs.
* Called by CallManager when a SipLeg receives a BYE.
*/
handleLegTerminated(terminatedLegId: string): void {
const terminatedLeg = this.legs.get(terminatedLegId);
if (!terminatedLeg) return;
// Remove the terminated leg.
terminatedLeg.onRtpReceived = null;
if (terminatedLeg.rtpPort) {
this.portPool.release(terminatedLeg.rtpPort);
}
this.legs.delete(terminatedLegId);
// If this is a 2-party call, hang up the other leg too.
if (this.legs.size <= 1) {
for (const [id, leg] of this.legs) {
if (leg.type === 'sip-device' || leg.type === 'sip-provider') {
(leg as SipLeg).sendHangup();
}
leg.teardown();
if (leg.rtpPort) {
this.portPool.release(leg.rtpPort);
}
}
this.legs.clear();
this.state = 'terminated';
this.log(`[call:${this.id}] terminated`);
this.onChange?.(this);
} else {
this.log(`[call:${this.id}] leg ${terminatedLegId} removed, ${this.legs.size} remaining`);
this.updateState();
}
}
// -------------------------------------------------------------------------
// Transfer
// -------------------------------------------------------------------------
/**
* Detach a leg from this call (without tearing it down).
* The leg can then be added to another call.
*/
detachLeg(legId: string): ILeg | null {
const leg = this.legs.get(legId);
if (!leg) return null;
leg.onRtpReceived = null;
this.legs.delete(legId);
this.log(`[call:${this.id}] detached leg ${legId}`);
this.updateState();
return leg;
}
// -------------------------------------------------------------------------
// Status
// -------------------------------------------------------------------------
getStatus(): ICallStatus {
return {
id: this.id,
state: this.state,
direction: this.direction,
callerNumber: this.callerNumber,
calleeNumber: this.calleeNumber,
providerUsed: this.providerUsed,
createdAt: this.createdAt,
duration: Math.floor((Date.now() - this.createdAt) / 1000),
legs: [...this.legs.values()].map((l) => l.getStatus()),
};
}
}