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,302 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
|
|
3
|
+
const { EventEmitter } = require("events");
|
|
4
|
+
const {
|
|
5
|
+
DEFAULT_CONFIG,
|
|
6
|
+
AMD_DECISIONS,
|
|
7
|
+
AUDIO_FORMATS,
|
|
8
|
+
} = require("./constants/defaults");
|
|
9
|
+
const PcmUtils = require("./audio/pcmUtils");
|
|
10
|
+
const VoiceActivityDetector = require("./audio/vad");
|
|
11
|
+
const GoertzelDetector = require("./dsp/goertzel");
|
|
12
|
+
const AnsweringMachineStateMachine = require("./classifier/stateMachine");
|
|
13
|
+
const TextPatternClassifier = require("./classifier/textPatterns");
|
|
14
|
+
|
|
15
|
+
class AnsweringMachineDetector extends EventEmitter {
|
|
16
|
+
/**
|
|
17
|
+
* @param {Object} options Configuration overrides
|
|
18
|
+
*/
|
|
19
|
+
constructor(options = {}) {
|
|
20
|
+
super();
|
|
21
|
+
|
|
22
|
+
this.options = Object.assign({}, DEFAULT_CONFIG, options);
|
|
23
|
+
|
|
24
|
+
// Audio format & native sample rate resolution
|
|
25
|
+
this.audioFormat = this.options.audioFormat || DEFAULT_CONFIG.audioFormat;
|
|
26
|
+
|
|
27
|
+
if (options.sampleRate) {
|
|
28
|
+
this.sampleRate = options.sampleRate;
|
|
29
|
+
} else if (
|
|
30
|
+
this.audioFormat === AUDIO_FORMATS.ULAW_8K ||
|
|
31
|
+
this.audioFormat === AUDIO_FORMATS.ALAW_8K ||
|
|
32
|
+
this.audioFormat === AUDIO_FORMATS.PCM_8K
|
|
33
|
+
) {
|
|
34
|
+
this.sampleRate = 8000;
|
|
35
|
+
} else if (this.audioFormat === AUDIO_FORMATS.PCM_24K) {
|
|
36
|
+
this.sampleRate = 24000;
|
|
37
|
+
} else if (this.audioFormat === AUDIO_FORMATS.PCM_32K) {
|
|
38
|
+
this.sampleRate = 32000;
|
|
39
|
+
} else if (this.audioFormat === AUDIO_FORMATS.PCM_44K) {
|
|
40
|
+
this.sampleRate = 44100;
|
|
41
|
+
} else if (this.audioFormat === AUDIO_FORMATS.PCM_48K) {
|
|
42
|
+
this.sampleRate = 48000;
|
|
43
|
+
} else {
|
|
44
|
+
this.sampleRate = 16000;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
this.frameSizeMs = this.options.frameSizeMs || 20;
|
|
48
|
+
|
|
49
|
+
// Native frame calculations (Zero Resampling!)
|
|
50
|
+
// 16-bit linear PCM has 2 bytes per sample
|
|
51
|
+
this.bytesPerMs = (this.sampleRate * 2) / 1000;
|
|
52
|
+
this.bytesPerFrame = this.bytesPerMs * this.frameSizeMs;
|
|
53
|
+
|
|
54
|
+
// Submodules (configured for native sample rate)
|
|
55
|
+
const subOptions = Object.assign({}, this.options, {
|
|
56
|
+
sampleRate: this.sampleRate,
|
|
57
|
+
});
|
|
58
|
+
this.vad = new VoiceActivityDetector(subOptions);
|
|
59
|
+
this.stateMachine = new AnsweringMachineStateMachine(subOptions);
|
|
60
|
+
|
|
61
|
+
if (this.options.enableBeepDetection) {
|
|
62
|
+
this.beepDetector = new GoertzelDetector(subOptions);
|
|
63
|
+
} else {
|
|
64
|
+
this.beepDetector = null;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// Residual audio buffer for frame alignment
|
|
68
|
+
this.audioBuffer = Buffer.alloc(0);
|
|
69
|
+
|
|
70
|
+
// Activity flags
|
|
71
|
+
this.wasSpeaking = false;
|
|
72
|
+
this.currentSpeechDurationMs = 0;
|
|
73
|
+
this.hasDecided = false;
|
|
74
|
+
this.lastDecision = null;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Process a single incoming chunk of audio (Buffer or base64 string)
|
|
79
|
+
* Natively processes at the audio's original sample rate with zero resampling.
|
|
80
|
+
*
|
|
81
|
+
* @param {Buffer|string} chunk
|
|
82
|
+
* @returns {Object} Current decision status
|
|
83
|
+
*/
|
|
84
|
+
processChunk(chunk) {
|
|
85
|
+
if (this.hasDecided) {
|
|
86
|
+
return this.stateMachine.getResult();
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
if (!chunk) return this.stateMachine.getResult();
|
|
90
|
+
|
|
91
|
+
// 1. Decode non-linear codecs to 16-bit PCM at native sample rate (Zero Resampling)
|
|
92
|
+
const nativePcm16 = PcmUtils.decodeToNativePcm16(chunk, {
|
|
93
|
+
audioFormat: this.audioFormat,
|
|
94
|
+
channels: this.options.channels,
|
|
95
|
+
endianness: this.options.endianness,
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
// 2. Append to accumulator buffer
|
|
99
|
+
this.audioBuffer = Buffer.concat([this.audioBuffer, nativePcm16]);
|
|
100
|
+
|
|
101
|
+
// 3. Process complete fixed-size native frames (e.g. 20ms at native rate)
|
|
102
|
+
while (this.audioBuffer.length >= this.bytesPerFrame && !this.hasDecided) {
|
|
103
|
+
const frame = this.audioBuffer.subarray(0, this.bytesPerFrame);
|
|
104
|
+
this.audioBuffer = this.audioBuffer.subarray(this.bytesPerFrame);
|
|
105
|
+
|
|
106
|
+
this._processFrame(frame);
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
return this.stateMachine.getResult();
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* Process a single aligned native audio frame
|
|
114
|
+
* @private
|
|
115
|
+
* @param {Buffer} frame - Native 20ms 16-bit linear PCM frame
|
|
116
|
+
*/
|
|
117
|
+
_processFrame(frame) {
|
|
118
|
+
const frameMs = this.frameSizeMs;
|
|
119
|
+
|
|
120
|
+
// 1. Run Tone / Beep Detector at native sample rate
|
|
121
|
+
if (this.beepDetector) {
|
|
122
|
+
const beepResult = this.beepDetector.process(frame, frameMs);
|
|
123
|
+
if (beepResult.isBeep) {
|
|
124
|
+
const result = this.stateMachine.forceDecision(
|
|
125
|
+
AMD_DECISIONS.BEEP,
|
|
126
|
+
`Voicemail beep tone detected (${beepResult.detectedFrequency}Hz)`,
|
|
127
|
+
0.99,
|
|
128
|
+
);
|
|
129
|
+
this._finalizeDecision(result, {
|
|
130
|
+
frequency: beepResult.detectedFrequency,
|
|
131
|
+
});
|
|
132
|
+
return;
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
// 2. Run Voice Activity Detector at native sample rate
|
|
137
|
+
const vadResult = this.vad.analyze(frame);
|
|
138
|
+
|
|
139
|
+
// 3. Emit granular speech transition events
|
|
140
|
+
if (vadResult.isSpeaking && !this.wasSpeaking) {
|
|
141
|
+
this.wasSpeaking = true;
|
|
142
|
+
this.currentSpeechDurationMs = 0;
|
|
143
|
+
this.emit("speech_start", {
|
|
144
|
+
rms: vadResult.rms,
|
|
145
|
+
snr: vadResult.snr,
|
|
146
|
+
zcrFrequencyHz: vadResult.zcrFrequencyHz,
|
|
147
|
+
timestamp: Date.now(),
|
|
148
|
+
});
|
|
149
|
+
} else if (!vadResult.isSpeaking && this.wasSpeaking) {
|
|
150
|
+
this.wasSpeaking = false;
|
|
151
|
+
this.emit("speech_end", {
|
|
152
|
+
durationMs: this.currentSpeechDurationMs,
|
|
153
|
+
timestamp: Date.now(),
|
|
154
|
+
});
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
if (vadResult.isSpeaking) {
|
|
158
|
+
this.currentSpeechDurationMs += frameMs;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
// 4. Update State Machine
|
|
162
|
+
const smResult = this.stateMachine.update(vadResult.isSpeaking, frameMs);
|
|
163
|
+
|
|
164
|
+
if (smResult.isFinal) {
|
|
165
|
+
this._finalizeDecision(smResult);
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/**
|
|
170
|
+
* Process an optional text transcript snippet (e.g. from an ASR/STT provider)
|
|
171
|
+
* to immediately detect carrier IVR phrases.
|
|
172
|
+
* @param {string} text
|
|
173
|
+
* @returns {Object|null}
|
|
174
|
+
*/
|
|
175
|
+
processTranscript(text) {
|
|
176
|
+
if (this.hasDecided || !text) return this.stateMachine.getResult();
|
|
177
|
+
|
|
178
|
+
const classification = TextPatternClassifier.classify(text);
|
|
179
|
+
if (classification.isVoicemail) {
|
|
180
|
+
const result = this.stateMachine.forceDecision(
|
|
181
|
+
AMD_DECISIONS.VOICEMAIL,
|
|
182
|
+
`Carrier IVR phrase detected: "${classification.matchedPattern}" (${classification.label})`,
|
|
183
|
+
classification.confidence,
|
|
184
|
+
);
|
|
185
|
+
this._finalizeDecision(result, {
|
|
186
|
+
transcriptMatch: classification.matchedPattern,
|
|
187
|
+
label: classification.label,
|
|
188
|
+
});
|
|
189
|
+
return result;
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
return null;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
/**
|
|
196
|
+
* Finalize the decision, emit events, and freeze processing
|
|
197
|
+
* @private
|
|
198
|
+
* @param {Object} result
|
|
199
|
+
* @param {Object} extraDetails
|
|
200
|
+
*/
|
|
201
|
+
_finalizeDecision(result, extraDetails = {}) {
|
|
202
|
+
if (this.hasDecided) return;
|
|
203
|
+
|
|
204
|
+
this.hasDecided = true;
|
|
205
|
+
this.lastDecision = Object.assign({}, result, { extra: extraDetails });
|
|
206
|
+
|
|
207
|
+
// Emit generic decision event
|
|
208
|
+
this.emit("decision", {
|
|
209
|
+
type: result.decision,
|
|
210
|
+
confidence: result.confidence,
|
|
211
|
+
reason: result.reason,
|
|
212
|
+
details: result.details,
|
|
213
|
+
extra: extraDetails,
|
|
214
|
+
});
|
|
215
|
+
|
|
216
|
+
// Emit specific convenience events
|
|
217
|
+
switch (result.decision) {
|
|
218
|
+
case AMD_DECISIONS.HUMAN:
|
|
219
|
+
this.emit("human", {
|
|
220
|
+
confidence: result.confidence,
|
|
221
|
+
reason: result.reason,
|
|
222
|
+
details: result.details,
|
|
223
|
+
});
|
|
224
|
+
break;
|
|
225
|
+
|
|
226
|
+
case AMD_DECISIONS.VOICEMAIL:
|
|
227
|
+
this.emit("voicemail", {
|
|
228
|
+
confidence: result.confidence,
|
|
229
|
+
reason: result.reason,
|
|
230
|
+
details: result.details,
|
|
231
|
+
extra: extraDetails,
|
|
232
|
+
});
|
|
233
|
+
break;
|
|
234
|
+
|
|
235
|
+
case AMD_DECISIONS.BEEP:
|
|
236
|
+
this.emit("beep", {
|
|
237
|
+
confidence: result.confidence,
|
|
238
|
+
frequency: extraDetails.frequency,
|
|
239
|
+
reason: result.reason,
|
|
240
|
+
details: result.details,
|
|
241
|
+
});
|
|
242
|
+
break;
|
|
243
|
+
|
|
244
|
+
case AMD_DECISIONS.SILENCE:
|
|
245
|
+
this.emit("silence", {
|
|
246
|
+
confidence: result.confidence,
|
|
247
|
+
reason: result.reason,
|
|
248
|
+
details: result.details,
|
|
249
|
+
});
|
|
250
|
+
break;
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
/**
|
|
255
|
+
* Node.js Stream / Transform write alias
|
|
256
|
+
* @param {Buffer|string} chunk
|
|
257
|
+
*/
|
|
258
|
+
write(chunk) {
|
|
259
|
+
this.processChunk(chunk);
|
|
260
|
+
return true;
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
/**
|
|
264
|
+
* Node.js Stream end alias
|
|
265
|
+
*/
|
|
266
|
+
end() {
|
|
267
|
+
if (!this.hasDecided) {
|
|
268
|
+
const result = this.stateMachine.update(
|
|
269
|
+
false,
|
|
270
|
+
this.options.maxAnalysisWindowMs,
|
|
271
|
+
);
|
|
272
|
+
if (result.isFinal) {
|
|
273
|
+
this._finalizeDecision(result);
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
this.emit("finish");
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
/**
|
|
280
|
+
* Retrieve current decision state
|
|
281
|
+
* @returns {Object}
|
|
282
|
+
*/
|
|
283
|
+
getDecision() {
|
|
284
|
+
return this.stateMachine.getResult();
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
/**
|
|
288
|
+
* Reset detector for a new call
|
|
289
|
+
*/
|
|
290
|
+
reset() {
|
|
291
|
+
this.audioBuffer = Buffer.alloc(0);
|
|
292
|
+
this.wasSpeaking = false;
|
|
293
|
+
this.currentSpeechDurationMs = 0;
|
|
294
|
+
this.hasDecided = false;
|
|
295
|
+
this.lastDecision = null;
|
|
296
|
+
this.vad.reset();
|
|
297
|
+
this.stateMachine.reset();
|
|
298
|
+
if (this.beepDetector) this.beepDetector.reset();
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
module.exports = AnsweringMachineDetector;
|
|
@@ -0,0 +1,198 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
|
|
3
|
+
const { AUDIO_FORMATS } = require("../constants/defaults");
|
|
4
|
+
|
|
5
|
+
// Precomputed µ-law expansion table for 16-bit linear PCM (G.711u)
|
|
6
|
+
const ULAW_TO_PCM16_TABLE = new Int16Array(256);
|
|
7
|
+
for (let i = 0; i < 256; i++) {
|
|
8
|
+
let u = ~i;
|
|
9
|
+
let sign = u & 0x80;
|
|
10
|
+
let exponent = (u >> 4) & 0x07;
|
|
11
|
+
let mantissa = u & 0x0f;
|
|
12
|
+
let sample = ((mantissa << 3) + 0x84) << exponent;
|
|
13
|
+
sample -= 0x84;
|
|
14
|
+
ULAW_TO_PCM16_TABLE[i] = sign ? -sample : sample;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
// Precomputed A-law expansion table for 16-bit linear PCM (G.711a)
|
|
18
|
+
const ALAW_TO_PCM16_TABLE = new Int16Array(256);
|
|
19
|
+
for (let i = 0; i < 256; i++) {
|
|
20
|
+
let a = i ^ 0x55;
|
|
21
|
+
let sign = a & 0x80;
|
|
22
|
+
let exponent = (a >> 4) & 0x07;
|
|
23
|
+
let mantissa = a & 0x0f;
|
|
24
|
+
let sample = 0;
|
|
25
|
+
if (exponent === 0) {
|
|
26
|
+
sample = (mantissa << 4) + 8;
|
|
27
|
+
} else {
|
|
28
|
+
sample = ((mantissa << 4) + 0x108) << (exponent - 1);
|
|
29
|
+
}
|
|
30
|
+
ALAW_TO_PCM16_TABLE[i] = sign ? -sample : sample;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
class PcmUtils {
|
|
34
|
+
/**
|
|
35
|
+
* Ensure input is a Node.js Buffer
|
|
36
|
+
* @param {Buffer|string} input
|
|
37
|
+
* @returns {Buffer}
|
|
38
|
+
*/
|
|
39
|
+
static toBuffer(input) {
|
|
40
|
+
if (Buffer.isBuffer(input)) return input;
|
|
41
|
+
if (typeof input === "string") return Buffer.from(input, "base64");
|
|
42
|
+
throw new TypeError("Audio input must be a Buffer or base64 string");
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Convert 8kHz µ-law (G.711u) buffer to 16-bit linear PCM (keeps native 8kHz)
|
|
47
|
+
* @param {Buffer} ulawBuf
|
|
48
|
+
* @returns {Buffer}
|
|
49
|
+
*/
|
|
50
|
+
static ulawToPcm16(ulawBuf) {
|
|
51
|
+
const pcmBuf = Buffer.alloc(ulawBuf.length * 2);
|
|
52
|
+
for (let i = 0; i < ulawBuf.length; i++) {
|
|
53
|
+
const sample = ULAW_TO_PCM16_TABLE[ulawBuf[i]];
|
|
54
|
+
pcmBuf.writeInt16LE(sample, i * 2);
|
|
55
|
+
}
|
|
56
|
+
return pcmBuf;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Convert 8kHz A-law (G.711a) buffer to 16-bit linear PCM (keeps native 8kHz)
|
|
61
|
+
* @param {Buffer} alawBuf
|
|
62
|
+
* @returns {Buffer}
|
|
63
|
+
*/
|
|
64
|
+
static alawToPcm16(alawBuf) {
|
|
65
|
+
const pcmBuf = Buffer.alloc(alawBuf.length * 2);
|
|
66
|
+
for (let i = 0; i < alawBuf.length; i++) {
|
|
67
|
+
const sample = ALAW_TO_PCM16_TABLE[alawBuf[i]];
|
|
68
|
+
pcmBuf.writeInt16LE(sample, i * 2);
|
|
69
|
+
}
|
|
70
|
+
return pcmBuf;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Convert 32-bit Float PCM (-1.0 to 1.0) to 16-bit linear PCM (keeps native sample rate)
|
|
75
|
+
* @param {Buffer} floatBuf
|
|
76
|
+
* @param {string} endianness - "LE" | "BE"
|
|
77
|
+
* @returns {Buffer}
|
|
78
|
+
*/
|
|
79
|
+
static float32ToPcm16(floatBuf, endianness = "LE") {
|
|
80
|
+
const numSamples = Math.floor(floatBuf.length / 4);
|
|
81
|
+
const pcmBuf = Buffer.alloc(numSamples * 2);
|
|
82
|
+
|
|
83
|
+
for (let i = 0; i < numSamples; i++) {
|
|
84
|
+
const floatVal =
|
|
85
|
+
endianness === "BE"
|
|
86
|
+
? floatBuf.readFloatBE(i * 4)
|
|
87
|
+
: floatBuf.readFloatLE(i * 4);
|
|
88
|
+
|
|
89
|
+
const clamped = Math.max(-1.0, Math.min(1.0, floatVal));
|
|
90
|
+
const int16 =
|
|
91
|
+
clamped < 0 ? Math.round(clamped * 32768) : Math.round(clamped * 32767);
|
|
92
|
+
|
|
93
|
+
pcmBuf.writeInt16LE(int16, i * 2);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
return pcmBuf;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* Convert 8-bit unsigned PCM (0-255) to 16-bit linear PCM (keeps native sample rate)
|
|
101
|
+
* @param {Buffer} pcm8Buf
|
|
102
|
+
* @returns {Buffer}
|
|
103
|
+
*/
|
|
104
|
+
static pcm8bitToPcm16(pcm8Buf) {
|
|
105
|
+
const pcmBuf = Buffer.alloc(pcm8Buf.length * 2);
|
|
106
|
+
for (let i = 0; i < pcm8Buf.length; i++) {
|
|
107
|
+
const sample = (pcm8Buf[i] - 128) << 8;
|
|
108
|
+
pcmBuf.writeInt16LE(sample, i * 2);
|
|
109
|
+
}
|
|
110
|
+
return pcmBuf;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* Downmix 2-channel Stereo 16-bit PCM to Mono (keeps native sample rate)
|
|
115
|
+
* @param {Buffer} stereoBuf
|
|
116
|
+
* @returns {Buffer}
|
|
117
|
+
*/
|
|
118
|
+
static downmixStereoToMono(stereoBuf) {
|
|
119
|
+
const numFrames = Math.floor(stereoBuf.length / 4);
|
|
120
|
+
const monoBuf = Buffer.alloc(numFrames * 2);
|
|
121
|
+
|
|
122
|
+
for (let i = 0; i < numFrames; i++) {
|
|
123
|
+
const left = stereoBuf.readInt16LE(i * 4);
|
|
124
|
+
const right = stereoBuf.readInt16LE(i * 4 + 2);
|
|
125
|
+
const mixed = Math.round((left + right) / 2);
|
|
126
|
+
monoBuf.writeInt16LE(mixed, i * 2);
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
return monoBuf;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* Convert any audio format to 16-bit linear PCM at its NATIVE sample rate (Zero-Resampling)
|
|
134
|
+
* @param {Buffer|string} input - Audio data
|
|
135
|
+
* @param {Object|string} options - Format string or options { audioFormat, channels, endianness }
|
|
136
|
+
* @returns {Buffer} 16-bit linear PCM at native sample rate
|
|
137
|
+
*/
|
|
138
|
+
static decodeToNativePcm16(input, options = {}) {
|
|
139
|
+
let buf = this.toBuffer(input);
|
|
140
|
+
|
|
141
|
+
let format = AUDIO_FORMATS.PCM_16K;
|
|
142
|
+
let channels = 1;
|
|
143
|
+
let endianness = "LE";
|
|
144
|
+
|
|
145
|
+
if (typeof options === "string") {
|
|
146
|
+
format = options;
|
|
147
|
+
} else if (typeof options === "object") {
|
|
148
|
+
format = options.audioFormat || format;
|
|
149
|
+
channels = options.channels || channels;
|
|
150
|
+
endianness = options.endianness || endianness;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
let pcm16;
|
|
154
|
+
switch (format) {
|
|
155
|
+
case AUDIO_FORMATS.ULAW_8K:
|
|
156
|
+
pcm16 = this.ulawToPcm16(buf);
|
|
157
|
+
break;
|
|
158
|
+
|
|
159
|
+
case AUDIO_FORMATS.ALAW_8K:
|
|
160
|
+
pcm16 = this.alawToPcm16(buf);
|
|
161
|
+
break;
|
|
162
|
+
|
|
163
|
+
case AUDIO_FORMATS.FLOAT_32:
|
|
164
|
+
pcm16 = this.float32ToPcm16(buf, endianness);
|
|
165
|
+
break;
|
|
166
|
+
|
|
167
|
+
case AUDIO_FORMATS.PCM_8BIT:
|
|
168
|
+
pcm16 = this.pcm8bitToPcm16(buf);
|
|
169
|
+
break;
|
|
170
|
+
|
|
171
|
+
default:
|
|
172
|
+
pcm16 = buf;
|
|
173
|
+
break;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
if (channels === 2) {
|
|
177
|
+
pcm16 = this.downmixStereoToMono(pcm16);
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
return pcm16;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/**
|
|
184
|
+
* Extract Int16Array of samples from a 16-bit PCM Buffer
|
|
185
|
+
* @param {Buffer} pcmBuf
|
|
186
|
+
* @returns {Int16Array}
|
|
187
|
+
*/
|
|
188
|
+
static pcmToInt16Array(pcmBuf) {
|
|
189
|
+
const sampleCount = Math.floor(pcmBuf.length / 2);
|
|
190
|
+
const samples = new Int16Array(sampleCount);
|
|
191
|
+
for (let i = 0; i < sampleCount; i++) {
|
|
192
|
+
samples[i] = pcmBuf.readInt16LE(i * 2);
|
|
193
|
+
}
|
|
194
|
+
return samples;
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
module.exports = PcmUtils;
|
package/src/audio/vad.js
ADDED
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
|
|
3
|
+
const { DEFAULT_CONFIG } = require("../constants/defaults");
|
|
4
|
+
const PcmUtils = require("./pcmUtils");
|
|
5
|
+
|
|
6
|
+
class VoiceActivityDetector {
|
|
7
|
+
/**
|
|
8
|
+
* @param {Object} options
|
|
9
|
+
*/
|
|
10
|
+
constructor(options = {}) {
|
|
11
|
+
this.sampleRate = options.sampleRate || DEFAULT_CONFIG.sampleRate;
|
|
12
|
+
this.speechThresholdRMS =
|
|
13
|
+
options.speechThresholdRMS || DEFAULT_CONFIG.speechThresholdRMS;
|
|
14
|
+
this.silenceThresholdRMS =
|
|
15
|
+
options.silenceThresholdRMS || DEFAULT_CONFIG.silenceThresholdRMS;
|
|
16
|
+
this.adaptiveNoiseFloor =
|
|
17
|
+
options.adaptiveNoiseFloor !== undefined
|
|
18
|
+
? options.adaptiveNoiseFloor
|
|
19
|
+
: DEFAULT_CONFIG.adaptiveNoiseFloor;
|
|
20
|
+
|
|
21
|
+
// Noise floor initialized to a clean baseline (80)
|
|
22
|
+
this.noiseFloor = this.silenceThresholdRMS;
|
|
23
|
+
this.noiseAlpha = 0.08;
|
|
24
|
+
|
|
25
|
+
// Hangover smoothing to bridge consonant-vowel transitions (40ms)
|
|
26
|
+
this.hangoverFrames = 0;
|
|
27
|
+
this.maxHangoverFrames = 2;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Analyze a 16-bit linear PCM audio chunk at its native sample rate
|
|
32
|
+
* @param {Buffer|Int16Array} chunk - 16-bit linear PCM chunk
|
|
33
|
+
* @returns {Object} { isSpeaking, rms, zcr, zcrFrequencyHz, snr, noiseFloor }
|
|
34
|
+
*/
|
|
35
|
+
analyze(chunk) {
|
|
36
|
+
const samples = Buffer.isBuffer(chunk)
|
|
37
|
+
? PcmUtils.pcmToInt16Array(chunk)
|
|
38
|
+
: chunk;
|
|
39
|
+
|
|
40
|
+
const N = samples.length;
|
|
41
|
+
if (N === 0) {
|
|
42
|
+
return {
|
|
43
|
+
isSpeaking: false,
|
|
44
|
+
rms: 0,
|
|
45
|
+
zcr: 0,
|
|
46
|
+
zcrFrequencyHz: 0,
|
|
47
|
+
snr: 0,
|
|
48
|
+
noiseFloor: this.noiseFloor,
|
|
49
|
+
};
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// 1. Calculate DC Bias / Mean of the Frame
|
|
53
|
+
let sum = 0;
|
|
54
|
+
for (let i = 0; i < N; i++) {
|
|
55
|
+
sum += samples[i];
|
|
56
|
+
}
|
|
57
|
+
const mean = sum / N;
|
|
58
|
+
|
|
59
|
+
// 2. AC-Coupled RMS Energy and Zero-Crossing Rate (removes hardware DC bias)
|
|
60
|
+
let sumSquares = 0;
|
|
61
|
+
let zeroCrossings = 0;
|
|
62
|
+
let prevSample = 0;
|
|
63
|
+
|
|
64
|
+
for (let i = 0; i < N; i++) {
|
|
65
|
+
const acSample = samples[i] - mean;
|
|
66
|
+
sumSquares += acSample * acSample;
|
|
67
|
+
|
|
68
|
+
if (i > 0) {
|
|
69
|
+
if (
|
|
70
|
+
(acSample >= 0 && prevSample < 0) ||
|
|
71
|
+
(acSample < 0 && prevSample >= 0)
|
|
72
|
+
) {
|
|
73
|
+
zeroCrossings++;
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
prevSample = acSample;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
const rms = Math.sqrt(sumSquares / N);
|
|
80
|
+
const zcr = parseFloat((zeroCrossings / N).toFixed(4));
|
|
81
|
+
// Physical zero-crossing frequency in Hz
|
|
82
|
+
const zcrFrequencyHz = Math.round(
|
|
83
|
+
(zeroCrossings * this.sampleRate) / (2 * N),
|
|
84
|
+
);
|
|
85
|
+
|
|
86
|
+
// 3. Adaptive Noise Floor Tracking
|
|
87
|
+
if (this.adaptiveNoiseFloor) {
|
|
88
|
+
if (rms < this.speechThresholdRMS * 0.7) {
|
|
89
|
+
this.noiseFloor =
|
|
90
|
+
this.noiseFloor * (1 - this.noiseAlpha) + rms * this.noiseAlpha;
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// Signal-to-Noise Ratio (SNR) in dB
|
|
95
|
+
const snr =
|
|
96
|
+
this.noiseFloor > 0
|
|
97
|
+
? 20 * Math.log10((rms + 1e-5) / Math.max(1, this.noiseFloor))
|
|
98
|
+
: 0;
|
|
99
|
+
|
|
100
|
+
// 4. Multi-Feature Speech Decision
|
|
101
|
+
const isHumanSpectralRange =
|
|
102
|
+
zcrFrequencyHz >= 50 && zcrFrequencyHz <= 6000;
|
|
103
|
+
|
|
104
|
+
// High energy speech (vowels)
|
|
105
|
+
const isHighEnergy = rms >= this.speechThresholdRMS && isHumanSpectralRange;
|
|
106
|
+
// Distinct voice with solid SNR above ambient noise floor
|
|
107
|
+
const isDistinctVoice = rms >= 140 && snr >= 4.0 && isHumanSpectralRange;
|
|
108
|
+
|
|
109
|
+
let isRawSpeaking = isHighEnergy || isDistinctVoice;
|
|
110
|
+
|
|
111
|
+
// 5. Hangover smoothing for natural speech continuation
|
|
112
|
+
if (isRawSpeaking) {
|
|
113
|
+
this.hangoverFrames = this.maxHangoverFrames;
|
|
114
|
+
} else if (this.hangoverFrames > 0) {
|
|
115
|
+
this.hangoverFrames--;
|
|
116
|
+
isRawSpeaking = true;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
return {
|
|
120
|
+
isSpeaking: isRawSpeaking,
|
|
121
|
+
rms: Math.round(rms),
|
|
122
|
+
zcr,
|
|
123
|
+
zcrFrequencyHz,
|
|
124
|
+
snr: parseFloat(snr.toFixed(2)),
|
|
125
|
+
noiseFloor: Math.round(this.noiseFloor),
|
|
126
|
+
};
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* Reset noise floor tracking and hangover
|
|
131
|
+
*/
|
|
132
|
+
reset() {
|
|
133
|
+
this.noiseFloor = this.silenceThresholdRMS;
|
|
134
|
+
this.hangoverFrames = 0;
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
module.exports = VoiceActivityDetector;
|