voice-amd 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,177 @@
1
+ "use strict";
2
+
3
+ const { AMD_DECISIONS, DEFAULT_CONFIG } = require("../constants/defaults");
4
+
5
+ class AnsweringMachineStateMachine {
6
+ /**
7
+ * @param {Object} options
8
+ */
9
+ constructor(options = {}) {
10
+ this.humanMaxGreetingDurationMs =
11
+ options.humanMaxGreetingDurationMs ||
12
+ DEFAULT_CONFIG.humanMaxGreetingDurationMs;
13
+ this.humanSilenceConfirmationMs =
14
+ options.humanSilenceConfirmationMs ||
15
+ DEFAULT_CONFIG.humanSilenceConfirmationMs;
16
+ this.voicemailContinuousDurationMs =
17
+ options.voicemailContinuousDurationMs ||
18
+ DEFAULT_CONFIG.voicemailContinuousDurationMs;
19
+ this.maxAnalysisWindowMs =
20
+ options.maxAnalysisWindowMs || DEFAULT_CONFIG.maxAnalysisWindowMs;
21
+ this.minSpeechDurationMs =
22
+ options.minSpeechDurationMs || DEFAULT_CONFIG.minSpeechDurationMs;
23
+
24
+ this.reset();
25
+ }
26
+
27
+ reset() {
28
+ this.totalAnalyzedMs = 0;
29
+ this.consecutiveSpeechMs = 0;
30
+ this.consecutiveSilenceMs = 0;
31
+ this.totalSpeechMs = 0;
32
+ this.speechBursts = 0;
33
+ this.lastSpeechBurstDurationMs = 0;
34
+ this.maxSpeechBurstDurationMs = 0;
35
+ this.decision = AMD_DECISIONS.UNKNOWN;
36
+ this.decisionReason = null;
37
+ this.confidence = 0;
38
+ this.isFinal = false;
39
+ }
40
+
41
+ /**
42
+ * Step the state machine with the latest VAD observation
43
+ * @param {boolean} isSpeaking
44
+ * @param {number} frameDurationMs
45
+ * @returns {Object} { decision, isFinal, confidence, reason, details }
46
+ */
47
+ update(isSpeaking, frameDurationMs = 20) {
48
+ if (this.isFinal) {
49
+ return this.getResult();
50
+ }
51
+
52
+ this.totalAnalyzedMs += frameDurationMs;
53
+
54
+ if (isSpeaking) {
55
+ this.totalSpeechMs += frameDurationMs;
56
+ this.consecutiveSpeechMs += frameDurationMs;
57
+ this.consecutiveSilenceMs = 0;
58
+
59
+ // Start of a new speech burst
60
+ if (this.consecutiveSpeechMs === frameDurationMs) {
61
+ this.speechBursts++;
62
+ }
63
+ this.lastSpeechBurstDurationMs = this.consecutiveSpeechMs;
64
+ if (this.consecutiveSpeechMs > this.maxSpeechBurstDurationMs) {
65
+ this.maxSpeechBurstDurationMs = this.consecutiveSpeechMs;
66
+ }
67
+ } else {
68
+ this.consecutiveSilenceMs += frameDurationMs;
69
+
70
+ // Reset consecutive speech if silence is long enough to count as a natural inter-word gap
71
+ if (this.consecutiveSilenceMs >= 200) {
72
+ this.consecutiveSpeechMs = 0;
73
+ }
74
+ }
75
+
76
+ // ─────────────────────────────────────────────────────────────
77
+ // Rule 1: Continuous Unbroken Speech / Voicemail Monologue
78
+ // ─────────────────────────────────────────────────────────────
79
+ const isHighDensityMonologue =
80
+ this.totalSpeechMs >= this.voicemailContinuousDurationMs &&
81
+ this.consecutiveSilenceMs < this.humanSilenceConfirmationMs;
82
+
83
+ if (this.consecutiveSpeechMs >= this.voicemailContinuousDurationMs || isHighDensityMonologue) {
84
+ this.decision = AMD_DECISIONS.VOICEMAIL;
85
+ this.isFinal = true;
86
+ this.confidence = Math.min(
87
+ 0.98,
88
+ 0.88 + (this.totalSpeechMs - this.voicemailContinuousDurationMs) / 2000,
89
+ );
90
+ this.decisionReason = `Unbroken/continuous speech detected for ${this.totalSpeechMs}ms without conversational pause`;
91
+ return this.getResult();
92
+ }
93
+
94
+ // ─────────────────────────────────────────────────────────────
95
+ // Rule 2: Human Greeting ("Hello?" -> Silence)
96
+ // ─────────────────────────────────────────────────────────────
97
+ const peakBurst = Math.max(
98
+ this.lastSpeechBurstDurationMs,
99
+ this.maxSpeechBurstDurationMs,
100
+ );
101
+ const hasValidGreeting =
102
+ peakBurst >= this.minSpeechDurationMs &&
103
+ this.totalSpeechMs <= this.humanMaxGreetingDurationMs;
104
+
105
+ const hasSilenceConfirmation =
106
+ this.consecutiveSilenceMs >= this.humanSilenceConfirmationMs;
107
+
108
+ if (hasValidGreeting && hasSilenceConfirmation) {
109
+ this.decision = AMD_DECISIONS.HUMAN;
110
+ this.isFinal = true;
111
+ this.confidence = 0.95;
112
+ this.decisionReason = `Human greeting detected (${this.totalSpeechMs}ms speech followed by ${this.consecutiveSilenceMs}ms pause)`;
113
+ return this.getResult();
114
+ }
115
+
116
+ // ─────────────────────────────────────────────────────────────
117
+ // Rule 3: Analysis Window Expired
118
+ // ─────────────────────────────────────────────────────────────
119
+ if (this.totalAnalyzedMs >= this.maxAnalysisWindowMs) {
120
+ this.isFinal = true;
121
+
122
+ if (this.totalSpeechMs < this.minSpeechDurationMs) {
123
+ this.decision = AMD_DECISIONS.SILENCE;
124
+ this.confidence = 0.92;
125
+ this.decisionReason = `No speech detected during ${this.totalAnalyzedMs}ms analysis window`;
126
+ } else if (this.totalSpeechMs > 2000) {
127
+ this.decision = AMD_DECISIONS.VOICEMAIL;
128
+ this.confidence = 0.88;
129
+ this.decisionReason = `High total speech activity (${this.totalSpeechMs}ms) across analysis window`;
130
+ } else {
131
+ this.decision = AMD_DECISIONS.HUMAN;
132
+ this.confidence = 0.78;
133
+ this.decisionReason = `Moderate intermittent speech activity (${this.totalSpeechMs}ms) consistent with human`;
134
+ }
135
+
136
+ return this.getResult();
137
+ }
138
+
139
+ return this.getResult();
140
+ }
141
+
142
+ /**
143
+ * Manually force a decision (e.g. from Beep detector or Text pattern)
144
+ * @param {string} decision - "BEEP" | "VOICEMAIL" | "HUMAN"
145
+ * @param {string} reason
146
+ * @param {number} confidence
147
+ */
148
+ forceDecision(decision, reason, confidence = 1.0) {
149
+ this.decision = decision;
150
+ this.decisionReason = reason;
151
+ this.confidence = confidence;
152
+ this.isFinal = true;
153
+ return this.getResult();
154
+ }
155
+
156
+ /**
157
+ * Retrieve current state snapshot
158
+ * @returns {Object}
159
+ */
160
+ getResult() {
161
+ return {
162
+ decision: this.decision,
163
+ isFinal: this.isFinal,
164
+ confidence: this.confidence,
165
+ reason: this.decisionReason,
166
+ details: {
167
+ totalAnalyzedMs: this.totalAnalyzedMs,
168
+ totalSpeechMs: this.totalSpeechMs,
169
+ consecutiveSpeechMs: this.consecutiveSpeechMs,
170
+ consecutiveSilenceMs: this.consecutiveSilenceMs,
171
+ speechBursts: this.speechBursts,
172
+ },
173
+ };
174
+ }
175
+ }
176
+
177
+ module.exports = AnsweringMachineStateMachine;
@@ -0,0 +1,145 @@
1
+ "use strict";
2
+
3
+ const GLOBAL_CARRIER_PATTERNS = [
4
+ // ── Indian Telecom IVR (Hindi / Hinglish) ──
5
+ {
6
+ regex:
7
+ /(jis\s*vyakti|kripya\s*kuch\s*samay|doosri\s*call|vyast\s*hai|switched\s*off|network\s*kshetra|se\s*bahar|pahunch\s*se\s*door|uttar\s*nahi\s*de|kripya\s*pratiksha|call\s*(forward|forwarded|divert|diverted|kiya\s*ja\s*raha)|line\s*par\s*bane\s*rahein|dhyan\s*de|aashvast\s*karein)/i,
8
+ label: "INDIAN_CARRIER_HINDI",
9
+ confidence: 0.95,
10
+ },
11
+ // ── Indian Telecom IVR (English) ──
12
+ {
13
+ regex:
14
+ /(subscriber\s*is\s*(currently\s*)?(busy|switched\s*off|not\s*reachable|out\s*of\s*coverage)|(number\s*you\s*(have\s*)?(dialled|called)|dialled\s*number)\s*is\s*(busy|switched\s*off|not\s*answering|out\s*of\s*coverage)|please\s*try\s*later|call\s*has\s*been\s*(forwarded|diverted)|currently\s*speaking\s*to\s*someone\s*else|please\s*hold\s*the\s*line)/i,
15
+ label: "INDIAN_CARRIER_ENGLISH",
16
+ confidence: 0.95,
17
+ },
18
+ // ── Indian Regional Languages ──
19
+ // Marathi
20
+ {
21
+ regex: /(व्यस्त\s*आहे|कॉल\s*करा|कवरेज\s*क्षेत्राच्या\s*बाहेर|उत्तर\s*देत\s*नाहीत|vyasta\s*aahe|uttar\s*det\s*naahit)/i,
22
+ label: "INDIAN_CARRIER_MARATHI",
23
+ confidence: 0.95,
24
+ },
25
+ // Tamil
26
+ {
27
+ regex: /(தொடர்பு\s*எல்லைக்கு\s*வெளியே|வேறொரு\s*அழைப்பில்|busy-aga\s*irukkirar|thodarbu\s*kollavum)/i,
28
+ label: "INDIAN_CARRIER_TAMIL",
29
+ confidence: 0.95,
30
+ },
31
+ // Telugu
32
+ {
33
+ regex: /(బిజీ|స్విచ్\s*ఆఫ్|పరిధిలో\s*లేరు|మరొక\s*కాల్|busy\s*ga|switch\s*off\s*chesaru)/i,
34
+ label: "INDIAN_CARRIER_TELUGU",
35
+ confidence: 0.95,
36
+ },
37
+ // Bengali
38
+ {
39
+ regex: /(ব্যস্ত\s*আছেন|সুইচ\s*অফ|নেটওয়ার্কের\s*বাইরে|byasto\s*aachen)/i,
40
+ label: "INDIAN_CARRIER_BENGALI",
41
+ confidence: 0.95,
42
+ },
43
+ // ── Global English Voicemail & Answering Machines ──
44
+ {
45
+ regex:
46
+ /(leave\s*a\s*message|after\s*the\s*(tone|beep)|at\s*the\s*tone|please\s*record\s*your\s*message|voicemail\s*(box|system)|mailbox\s*is\s*full|not\s*available\s*to\s*take\s*your\s*call|record\s*your\s*name)/i,
47
+ label: "GLOBAL_VOICEMAIL_ENGLISH",
48
+ confidence: 0.98,
49
+ },
50
+ // ── Spanish (Español) ──
51
+ {
52
+ regex:
53
+ /(deje\s*su\s*mensaje|despu[eé]s\s*del\s*tono|buz[oó]n\s*de\s*voz|no\s*est[aá]\s*disponible|intente\s*m[aá]s\s*tarde|el\s*n[uú]mero\s*que\s*(usted\s*)?marca\s*(se\s*encuentra\s*)?est[aá]\s*ocupado|se\s*encuentra\s*ocupado)/i,
54
+ label: "GLOBAL_CARRIER_SPANISH",
55
+ confidence: 0.96,
56
+ },
57
+ // ── French (Français) ──
58
+ {
59
+ regex:
60
+ /(laissez\s*un\s*message|apr[eè]s\s*le\s*bip|messagerie\s*vocale|votre\s*correspondant\s*n'est\s*pas\s*joignable|veuillez\s*rappeler\s*ult[eé]rieurement)/i,
61
+ label: "GLOBAL_CARRIER_FRENCH",
62
+ confidence: 0.96,
63
+ },
64
+ // ── German (Deutsch) ──
65
+ {
66
+ regex:
67
+ /(hinterlassen\s*sie\s*eine\s*nachricht|nach\s*dem\s*signalton|mailbox|der\s*teilnehmer\s*ist\s*vor[uü]bergehend\s*nicht\s*erreichbar|bitte\s*versuchen\s*sie\s*es\s*sp[aä]ter)/i,
68
+ label: "GLOBAL_CARRIER_GERMAN",
69
+ confidence: 0.96,
70
+ },
71
+ // ── Portuguese (Português) ──
72
+ {
73
+ regex:
74
+ /(deixe\s*seu\s*recado|ap[oó]s\s*o\s*sinal|caixa\s*postal|o\s*n[uú]mero\s*est[aá]\s*ocupado|fora\s*da\s*área\s*de\s*cobertura)/i,
75
+ label: "GLOBAL_CARRIER_PORTUGUESE",
76
+ confidence: 0.96,
77
+ },
78
+ // ── Arabic (العربية) ──
79
+ {
80
+ regex:
81
+ /(البريد\s*الصوتي|اترك\s*رسالة|بعد\s*سماع\s*النغمة|المشترك\s*الذي\s*تطلبه\s*غير\s*متاح|مغلق\s*حاليا)/i,
82
+ label: "GLOBAL_CARRIER_ARABIC",
83
+ confidence: 0.96,
84
+ },
85
+ // ── Automated IVR Menus ──
86
+ {
87
+ regex:
88
+ /(press\s*(1|2|3|one|two|three|nine|zero)|for\s*english\s*press|hindi\s*ke\s*liye|main\s*menu|to\s*speak\s*to\s*our\s*executive|para\s*español\s*oprima)/i,
89
+ label: "IVR_MENU",
90
+ confidence: 0.92,
91
+ },
92
+ ];
93
+
94
+ class TextPatternClassifier {
95
+ constructor() {
96
+ this.customPatterns = [];
97
+ }
98
+
99
+ /**
100
+ * Register custom regex patterns
101
+ * @param {Array<{ regex: RegExp, label: string, confidence?: number }>} patterns
102
+ */
103
+ static registerCustomPatterns(patterns) {
104
+ if (Array.isArray(patterns)) {
105
+ GLOBAL_CARRIER_PATTERNS.unshift(...patterns);
106
+ }
107
+ }
108
+
109
+ /**
110
+ * Check if a transcript snippet contains known answering machine / IVR phrases in any language
111
+ * @param {string} text
112
+ * @returns {Object} { isVoicemail: boolean, matchedPattern: string|null, label: string|null, confidence: number }
113
+ */
114
+ static classify(text) {
115
+ if (!text || typeof text !== "string") {
116
+ return { isVoicemail: false, matchedPattern: null, label: null, confidence: 0 };
117
+ }
118
+
119
+ const cleanText = text.trim();
120
+ if (cleanText.length === 0) {
121
+ return { isVoicemail: false, matchedPattern: null, label: null, confidence: 0 };
122
+ }
123
+
124
+ for (const item of GLOBAL_CARRIER_PATTERNS) {
125
+ const match = cleanText.match(item.regex);
126
+ if (match) {
127
+ return {
128
+ isVoicemail: true,
129
+ matchedPattern: match[0],
130
+ label: item.label,
131
+ confidence: item.confidence || 0.95,
132
+ };
133
+ }
134
+ }
135
+
136
+ return {
137
+ isVoicemail: false,
138
+ matchedPattern: null,
139
+ label: null,
140
+ confidence: 0,
141
+ };
142
+ }
143
+ }
144
+
145
+ module.exports = TextPatternClassifier;
@@ -0,0 +1,55 @@
1
+ "use strict";
2
+
3
+ const AMD_DECISIONS = {
4
+ UNKNOWN: "UNKNOWN",
5
+ HUMAN: "HUMAN",
6
+ VOICEMAIL: "VOICEMAIL",
7
+ BEEP: "BEEP",
8
+ SILENCE: "SILENCE",
9
+ };
10
+
11
+ const AUDIO_FORMATS = {
12
+ PCM_16K: "16kpcm",
13
+ PCM_8K: "8kpcm",
14
+ PCM_24K: "24kpcm",
15
+ PCM_32K: "32kpcm",
16
+ PCM_44K: "44.1kpcm",
17
+ PCM_48K: "48kpcm",
18
+ FLOAT_32: "float32",
19
+ ULAW_8K: "8kulaw",
20
+ ALAW_8K: "8kalaw",
21
+ PCM_8BIT: "8bitpcm",
22
+ };
23
+
24
+ const DEFAULT_CONFIG = {
25
+ // Audio format & rate
26
+ audioFormat: AUDIO_FORMATS.PCM_16K, // Default: 16kHz 16-bit mono linear PCM
27
+ sampleRate: 16000, // Default sample rate in Hz
28
+ channels: 1, // 1 = Mono, 2 = Stereo (auto-downmixed)
29
+ endianness: "LE", // "LE" (Little Endian) or "BE" (Big Endian)
30
+ frameSizeMs: 20, // 20ms audio frame processing
31
+
32
+ // Voice Activity Detection (VAD) thresholds
33
+ speechThresholdRMS: 400, // RMS energy threshold to trigger standard speech
34
+ silenceThresholdRMS: 80, // Energy below which is considered true silence
35
+ adaptiveNoiseFloor: true, // Dynamically adjust noise floor for cellular PSTN noise
36
+ minSpeechDurationMs: 60, // Minimum speech burst (60ms = 3 frames for brisk "Yes"/"No"/"Hi")
37
+
38
+ // Timing & Decision thresholds
39
+ humanMaxGreetingDurationMs: 1800, // Human "Hello" / greeting is typically 300ms - 1800ms
40
+ humanSilenceConfirmationMs: 400, // Pause after human greeting confirming listening state
41
+ voicemailContinuousDurationMs: 2000, // Voicemail / IVR speaks continuously for >= 2.0s without natural pause
42
+ maxAnalysisWindowMs: 5000, // Total maximum time to listen before making fallback decision
43
+
44
+ // Tone / Voicemail Beep Detection (Goertzel Algorithm)
45
+ enableBeepDetection: true, // Detect voicemail recording beep tones
46
+ beepTargetFrequencies: [1000, 950, 850, 700, 440], // Global carrier, PBX, and telecom beep frequencies (700Hz Asterisk, 950Hz US, 1000Hz standard, 440Hz UK)
47
+ beepMinDurationMs: 100, // Minimum sustained tone duration
48
+ beepEnergyRatioThreshold: 0.80, // Ratio of target frequency power to total frame power (sinusoid vs multi-harmonic voice)
49
+ };
50
+
51
+ module.exports = {
52
+ AMD_DECISIONS,
53
+ AUDIO_FORMATS,
54
+ DEFAULT_CONFIG,
55
+ };
@@ -0,0 +1,125 @@
1
+ "use strict";
2
+
3
+ const { DEFAULT_CONFIG } = require("../constants/defaults");
4
+ const PcmUtils = require("../audio/pcmUtils");
5
+
6
+ class GoertzelDetector {
7
+ /**
8
+ * @param {Object} options
9
+ */
10
+ constructor(options = {}) {
11
+ this.sampleRate = options.sampleRate || DEFAULT_CONFIG.sampleRate;
12
+ this.targetFrequencies =
13
+ options.beepTargetFrequencies || DEFAULT_CONFIG.beepTargetFrequencies;
14
+ this.energyRatioThreshold =
15
+ options.beepEnergyRatioThreshold ||
16
+ DEFAULT_CONFIG.beepEnergyRatioThreshold;
17
+ this.minDurationMs =
18
+ options.beepMinDurationMs || DEFAULT_CONFIG.beepMinDurationMs;
19
+
20
+ this.sustainedToneMs = 0;
21
+ this.detectedFrequency = null;
22
+
23
+ // Precalculate Goertzel coefficients for target frequencies
24
+ this.coefficients = {};
25
+ for (const freq of this.targetFrequencies) {
26
+ const normalizedFreq = freq / this.sampleRate;
27
+ this.coefficients[freq] = 2.0 * Math.cos(2.0 * Math.PI * normalizedFreq);
28
+ }
29
+ }
30
+
31
+ /**
32
+ * Process a 16kHz PCM audio chunk and test for single-tone beep
33
+ * @param {Buffer|Int16Array} chunk
34
+ * @param {number} chunkDurationMs
35
+ * @returns {Object} { isBeep, detectedFrequency, peakPowerRatio, sustainedDurationMs }
36
+ */
37
+ process(chunk, chunkDurationMs = 20) {
38
+ const samples = Buffer.isBuffer(chunk)
39
+ ? PcmUtils.pcmToInt16Array(chunk)
40
+ : chunk;
41
+
42
+ const N = samples.length;
43
+ if (N === 0) {
44
+ return {
45
+ isBeep: false,
46
+ detectedFrequency: null,
47
+ peakPowerRatio: 0,
48
+ sustainedDurationMs: this.sustainedToneMs,
49
+ };
50
+ }
51
+
52
+ // Calculate total energy of the chunk
53
+ let totalEnergy = 0;
54
+ for (let i = 0; i < N; i++) {
55
+ totalEnergy += samples[i] * samples[i];
56
+ }
57
+
58
+ if (totalEnergy < 1e6) {
59
+ // Audio chunk is too quiet to contain an audible beep tone
60
+ this.sustainedToneMs = 0;
61
+ this.detectedFrequency = null;
62
+ return {
63
+ isBeep: false,
64
+ detectedFrequency: null,
65
+ peakPowerRatio: 0,
66
+ sustainedDurationMs: 0,
67
+ };
68
+ }
69
+
70
+ let maxPower = 0;
71
+ let strongestFreq = null;
72
+
73
+ // Run Goertzel filter for each target frequency
74
+ for (const freq of this.targetFrequencies) {
75
+ const coeff = this.coefficients[freq];
76
+ let s_prev = 0;
77
+ let s_prev2 = 0;
78
+
79
+ for (let i = 0; i < N; i++) {
80
+ const s = samples[i] + coeff * s_prev - s_prev2;
81
+ s_prev2 = s_prev;
82
+ s_prev = s;
83
+ }
84
+
85
+ // Compute power at target frequency
86
+ const power = s_prev * s_prev + s_prev2 * s_prev2 - coeff * s_prev * s_prev2;
87
+
88
+ if (power > maxPower) {
89
+ maxPower = power;
90
+ strongestFreq = freq;
91
+ }
92
+ }
93
+
94
+ // Ratio of peak frequency power to total frame energy
95
+ const powerRatio = maxPower / (totalEnergy * (N / 2) + 1e-5);
96
+ const hasSharpTone = powerRatio >= this.energyRatioThreshold;
97
+
98
+ if (hasSharpTone) {
99
+ this.sustainedToneMs += chunkDurationMs;
100
+ this.detectedFrequency = strongestFreq;
101
+ } else {
102
+ this.sustainedToneMs = 0;
103
+ this.detectedFrequency = null;
104
+ }
105
+
106
+ const isBeep = this.sustainedToneMs >= this.minDurationMs;
107
+
108
+ return {
109
+ isBeep,
110
+ detectedFrequency: this.detectedFrequency,
111
+ peakPowerRatio: parseFloat(powerRatio.toFixed(3)),
112
+ sustainedDurationMs: this.sustainedToneMs,
113
+ };
114
+ }
115
+
116
+ /**
117
+ * Reset detector state
118
+ */
119
+ reset() {
120
+ this.sustainedToneMs = 0;
121
+ this.detectedFrequency = null;
122
+ }
123
+ }
124
+
125
+ module.exports = GoertzelDetector;