feat(call, voicemail, ivr): add voicemail and IVR call flows with DTMF handling, prompt playback, recording, and dashboard management
This commit is contained in:
336
ts/call/system-leg.ts
Normal file
336
ts/call/system-leg.ts
Normal file
@@ -0,0 +1,336 @@
|
||||
/**
|
||||
* 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,
|
||||
};
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user