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.
- package/LICENSE +21 -0
- package/README.md +334 -0
- package/index.d.ts +133 -0
- package/index.js +23 -0
- package/package.json +56 -0
- package/src/AnsweringMachineDetector.js +302 -0
- package/src/audio/pcmUtils.js +198 -0
- package/src/audio/vad.js +138 -0
- package/src/classifier/stateMachine.js +177 -0
- package/src/classifier/textPatterns.js +145 -0
- package/src/constants/defaults.js +55 -0
- package/src/dsp/goertzel.js +125 -0
|
@@ -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;
|