Files
siprouter/ts/call/system-leg.ts

337 lines
11 KiB
TypeScript

/**
* 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,
};
}
}