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