273 lines
8.7 KiB
TypeScript
273 lines
8.7 KiB
TypeScript
|
|
/**
|
||
|
|
* DTMF detection — parses RFC 2833 telephone-event RTP packets
|
||
|
|
* and SIP INFO (application/dtmf-relay) messages.
|
||
|
|
*
|
||
|
|
* Designed to be attached to any leg or RTP stream. The detector
|
||
|
|
* deduplicates repeated telephone-event packets (same digit is sent
|
||
|
|
* multiple times with increasing duration) and fires a callback
|
||
|
|
* once per detected digit.
|
||
|
|
*/
|
||
|
|
|
||
|
|
import { Buffer } from 'node:buffer';
|
||
|
|
import type { SipMessage } from '../sip/index.ts';
|
||
|
|
|
||
|
|
// ---------------------------------------------------------------------------
|
||
|
|
// Types
|
||
|
|
// ---------------------------------------------------------------------------
|
||
|
|
|
||
|
|
/** A single detected DTMF digit. */
|
||
|
|
export interface IDtmfDigit {
|
||
|
|
/** The digit character: '0'-'9', '*', '#', 'A'-'D'. */
|
||
|
|
digit: string;
|
||
|
|
/** Duration in milliseconds. */
|
||
|
|
durationMs: number;
|
||
|
|
/** Detection source. */
|
||
|
|
source: 'rfc2833' | 'sip-info';
|
||
|
|
/** Wall-clock timestamp when the digit was detected. */
|
||
|
|
timestamp: number;
|
||
|
|
}
|
||
|
|
|
||
|
|
/** Callback fired once per detected DTMF digit. */
|
||
|
|
export type TDtmfCallback = (digit: IDtmfDigit) => void;
|
||
|
|
|
||
|
|
// ---------------------------------------------------------------------------
|
||
|
|
// Constants
|
||
|
|
// ---------------------------------------------------------------------------
|
||
|
|
|
||
|
|
/** RFC 2833 event ID → character mapping. */
|
||
|
|
const EVENT_CHARS: string[] = [
|
||
|
|
'0', '1', '2', '3', '4', '5', '6', '7', '8', '9',
|
||
|
|
'*', '#', 'A', 'B', 'C', 'D',
|
||
|
|
];
|
||
|
|
|
||
|
|
/** Safety timeout: report digit if no End packet arrives within this many ms. */
|
||
|
|
const SAFETY_TIMEOUT_MS = 200;
|
||
|
|
|
||
|
|
// ---------------------------------------------------------------------------
|
||
|
|
// DtmfDetector
|
||
|
|
// ---------------------------------------------------------------------------
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Detects DTMF digits from RFC 2833 RTP packets and SIP INFO messages.
|
||
|
|
*
|
||
|
|
* Usage:
|
||
|
|
* ```
|
||
|
|
* const detector = new DtmfDetector(log);
|
||
|
|
* detector.onDigit = (d) => console.log('DTMF:', d.digit);
|
||
|
|
* // Feed every RTP packet (detector checks PT internally):
|
||
|
|
* detector.processRtp(rtpPacket);
|
||
|
|
* // Or feed a SIP INFO message:
|
||
|
|
* detector.processSipInfo(sipMsg);
|
||
|
|
* ```
|
||
|
|
*/
|
||
|
|
export class DtmfDetector {
|
||
|
|
/** Callback fired once per detected digit. */
|
||
|
|
onDigit: TDtmfCallback | null = null;
|
||
|
|
|
||
|
|
/** Negotiated telephone-event payload type (default 101). */
|
||
|
|
private telephoneEventPt: number;
|
||
|
|
|
||
|
|
/** Clock rate for duration calculation (default 8000 Hz). */
|
||
|
|
private clockRate: number;
|
||
|
|
|
||
|
|
// -- Deduplication state for RFC 2833 --
|
||
|
|
/** Event ID of the digit currently being received. */
|
||
|
|
private currentEventId: number | null = null;
|
||
|
|
/** RTP timestamp of the first packet for the current event. */
|
||
|
|
private currentEventTs: number | null = null;
|
||
|
|
/** Whether the current event has already been reported. */
|
||
|
|
private currentEventReported = false;
|
||
|
|
/** Latest duration value seen (in clock ticks). */
|
||
|
|
private currentEventDuration = 0;
|
||
|
|
/** Latest volume value seen (dBm0, 0 = loudest). */
|
||
|
|
private currentEventVolume = 0;
|
||
|
|
/** Safety timer: fires if no End packet arrives. */
|
||
|
|
private safetyTimer: ReturnType<typeof setTimeout> | null = null;
|
||
|
|
|
||
|
|
private log: (msg: string) => void;
|
||
|
|
|
||
|
|
constructor(
|
||
|
|
log: (msg: string) => void,
|
||
|
|
telephoneEventPt = 101,
|
||
|
|
clockRate = 8000,
|
||
|
|
) {
|
||
|
|
this.log = log;
|
||
|
|
this.telephoneEventPt = telephoneEventPt;
|
||
|
|
this.clockRate = clockRate;
|
||
|
|
}
|
||
|
|
|
||
|
|
// -------------------------------------------------------------------------
|
||
|
|
// RFC 2833 RTP processing
|
||
|
|
// -------------------------------------------------------------------------
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Feed an RTP packet. Checks PT; ignores non-DTMF packets.
|
||
|
|
* Expects the full RTP packet (12-byte header + payload).
|
||
|
|
*/
|
||
|
|
processRtp(data: Buffer): void {
|
||
|
|
if (data.length < 16) return; // 12-byte header + 4-byte telephone-event payload minimum
|
||
|
|
|
||
|
|
const pt = data[1] & 0x7f;
|
||
|
|
if (pt !== this.telephoneEventPt) return;
|
||
|
|
|
||
|
|
// Parse RTP header fields we need.
|
||
|
|
const marker = (data[1] & 0x80) !== 0;
|
||
|
|
const rtpTimestamp = data.readUInt32BE(4);
|
||
|
|
|
||
|
|
// Parse telephone-event payload (4 bytes starting at offset 12).
|
||
|
|
const eventId = data[12];
|
||
|
|
const endBit = (data[13] & 0x80) !== 0;
|
||
|
|
const volume = data[13] & 0x3f;
|
||
|
|
const duration = data.readUInt16BE(14);
|
||
|
|
|
||
|
|
// Validate event ID.
|
||
|
|
if (eventId >= EVENT_CHARS.length) return;
|
||
|
|
|
||
|
|
// Detect new event: marker bit, different event ID, or different RTP timestamp.
|
||
|
|
const isNewEvent =
|
||
|
|
marker ||
|
||
|
|
eventId !== this.currentEventId ||
|
||
|
|
rtpTimestamp !== this.currentEventTs;
|
||
|
|
|
||
|
|
if (isNewEvent) {
|
||
|
|
// If there was an unreported previous event, report it now (fallback).
|
||
|
|
this.reportPendingEvent();
|
||
|
|
|
||
|
|
// Start tracking the new event.
|
||
|
|
this.currentEventId = eventId;
|
||
|
|
this.currentEventTs = rtpTimestamp;
|
||
|
|
this.currentEventReported = false;
|
||
|
|
this.currentEventDuration = duration;
|
||
|
|
this.currentEventVolume = volume;
|
||
|
|
|
||
|
|
// Start safety timer.
|
||
|
|
this.clearSafetyTimer();
|
||
|
|
this.safetyTimer = setTimeout(() => {
|
||
|
|
this.reportPendingEvent();
|
||
|
|
}, SAFETY_TIMEOUT_MS);
|
||
|
|
}
|
||
|
|
|
||
|
|
// Update duration (it increases with each retransmission).
|
||
|
|
if (duration > this.currentEventDuration) {
|
||
|
|
this.currentEventDuration = duration;
|
||
|
|
}
|
||
|
|
|
||
|
|
// Report on End bit (first time only).
|
||
|
|
if (endBit && !this.currentEventReported) {
|
||
|
|
this.currentEventReported = true;
|
||
|
|
this.clearSafetyTimer();
|
||
|
|
|
||
|
|
const digit = EVENT_CHARS[eventId];
|
||
|
|
const durationMs = (this.currentEventDuration / this.clockRate) * 1000;
|
||
|
|
|
||
|
|
this.log(`[dtmf] RFC 2833 digit '${digit}' (${Math.round(durationMs)}ms)`);
|
||
|
|
this.onDigit?.({
|
||
|
|
digit,
|
||
|
|
durationMs,
|
||
|
|
source: 'rfc2833',
|
||
|
|
timestamp: Date.now(),
|
||
|
|
});
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
/** Report a pending (unreported) event — called by safety timer or on new event start. */
|
||
|
|
private reportPendingEvent(): void {
|
||
|
|
if (
|
||
|
|
this.currentEventId !== null &&
|
||
|
|
!this.currentEventReported &&
|
||
|
|
this.currentEventId < EVENT_CHARS.length
|
||
|
|
) {
|
||
|
|
this.currentEventReported = true;
|
||
|
|
this.clearSafetyTimer();
|
||
|
|
|
||
|
|
const digit = EVENT_CHARS[this.currentEventId];
|
||
|
|
const durationMs = (this.currentEventDuration / this.clockRate) * 1000;
|
||
|
|
|
||
|
|
this.log(`[dtmf] RFC 2833 digit '${digit}' (${Math.round(durationMs)}ms, safety timeout)`);
|
||
|
|
this.onDigit?.({
|
||
|
|
digit,
|
||
|
|
durationMs,
|
||
|
|
source: 'rfc2833',
|
||
|
|
timestamp: Date.now(),
|
||
|
|
});
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
private clearSafetyTimer(): void {
|
||
|
|
if (this.safetyTimer) {
|
||
|
|
clearTimeout(this.safetyTimer);
|
||
|
|
this.safetyTimer = null;
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
// -------------------------------------------------------------------------
|
||
|
|
// SIP INFO processing
|
||
|
|
// -------------------------------------------------------------------------
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Parse a SIP INFO message carrying DTMF.
|
||
|
|
* Supports Content-Type: application/dtmf-relay (Signal=X / Duration=Y).
|
||
|
|
*/
|
||
|
|
processSipInfo(msg: SipMessage): void {
|
||
|
|
const ct = (msg.getHeader('Content-Type') || '').toLowerCase();
|
||
|
|
if (!ct.includes('application/dtmf-relay') && !ct.includes('application/dtmf')) return;
|
||
|
|
|
||
|
|
const body = msg.body || '';
|
||
|
|
|
||
|
|
if (ct.includes('application/dtmf-relay')) {
|
||
|
|
// Format: "Signal= 5\r\nDuration= 160\r\n"
|
||
|
|
const signalMatch = body.match(/Signal\s*=\s*(\S+)/i);
|
||
|
|
const durationMatch = body.match(/Duration\s*=\s*(\d+)/i);
|
||
|
|
if (!signalMatch) return;
|
||
|
|
|
||
|
|
const signal = signalMatch[1];
|
||
|
|
const durationTicks = durationMatch ? parseInt(durationMatch[1], 10) : 160;
|
||
|
|
|
||
|
|
// Validate digit.
|
||
|
|
if (signal.length !== 1 || !/[0-9*#A-Da-d]/.test(signal)) return;
|
||
|
|
const digit = signal.toUpperCase();
|
||
|
|
const durationMs = (durationTicks / this.clockRate) * 1000;
|
||
|
|
|
||
|
|
this.log(`[dtmf] SIP INFO digit '${digit}' (${Math.round(durationMs)}ms)`);
|
||
|
|
this.onDigit?.({
|
||
|
|
digit,
|
||
|
|
durationMs,
|
||
|
|
source: 'sip-info',
|
||
|
|
timestamp: Date.now(),
|
||
|
|
});
|
||
|
|
} else if (ct.includes('application/dtmf')) {
|
||
|
|
// Simple format: just the digit character in the body.
|
||
|
|
const digit = body.trim().toUpperCase();
|
||
|
|
if (digit.length !== 1 || !/[0-9*#A-D]/.test(digit)) return;
|
||
|
|
|
||
|
|
this.log(`[dtmf] SIP INFO digit '${digit}' (application/dtmf)`);
|
||
|
|
this.onDigit?.({
|
||
|
|
digit,
|
||
|
|
durationMs: 250, // default duration
|
||
|
|
source: 'sip-info',
|
||
|
|
timestamp: Date.now(),
|
||
|
|
});
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
// -------------------------------------------------------------------------
|
||
|
|
// Lifecycle
|
||
|
|
// -------------------------------------------------------------------------
|
||
|
|
|
||
|
|
/** Reset detection state (e.g., between calls). */
|
||
|
|
reset(): void {
|
||
|
|
this.currentEventId = null;
|
||
|
|
this.currentEventTs = null;
|
||
|
|
this.currentEventReported = false;
|
||
|
|
this.currentEventDuration = 0;
|
||
|
|
this.currentEventVolume = 0;
|
||
|
|
this.clearSafetyTimer();
|
||
|
|
}
|
||
|
|
|
||
|
|
/** Clean up timers and references. */
|
||
|
|
destroy(): void {
|
||
|
|
this.clearSafetyTimer();
|
||
|
|
this.onDigit = null;
|
||
|
|
}
|
||
|
|
}
|