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.
256 lines
7.7 KiB
TypeScript
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()),
|
|
};
|
|
}
|
|
}
|