Files
siprouter/ts/call/dtmf-detector.ts

273 lines
8.7 KiB
TypeScript
Raw Normal View History

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