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