337 lines
11 KiB
TypeScript
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,
|
||
|
|
};
|
||
|
|
}
|
||
|
|
}
|