├── .gitignore ├── .npmignore ├── README.md ├── index.d.ts ├── index.js ├── package-lock.json ├── package.json └── src ├── comparator.js ├── detector.js ├── dtw ├── distance.js ├── dtw.js └── matrix.js ├── extractor.js ├── keyword.js ├── utils.js └── vad.js /.gitignore: -------------------------------------------------------------------------------- 1 | /node_modules 2 | /wavs 3 | -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | wavs 3 | .gitignore 4 | record.js 5 | test.js 6 | try.js 7 | *.wav 8 | *.raw -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # node-personal-wakeword 2 | 3 | Based on https://medium.com/snips-ai/machine-learning-on-voice-a-gentle-introduction-with-snips-personal-wake-word-detector-133bd6fb568e 4 | 5 | ### Installation 6 | 7 | ```bash 8 | npm i @mathquis/node-personal-wakeword 9 | ``` 10 | 11 | ### Usage 12 | 13 | ```javascript 14 | const WakewordDetector = require('@mathquis/node-personal-wakeword') 15 | const Mic = require('mic') 16 | const Stream = require('stream') 17 | 18 | async function main() { 19 | // Create a new wakeword detection engine 20 | let detector = new WakewordDetector({ 21 | /* 22 | sampleRate: 16000, 23 | bitLength: 16, 24 | frameShiftMS: 10.0, 25 | frameLengthMS: 30.0, // Must be a multiple of frameShiftMS 26 | vad: true, // Use VAD detection 27 | vadMode: WakewordDetector.VadMode.AGGRESSIVE, // See node-vad modes 28 | vadDebounceTime: 500, 29 | band: 5, // DTW window width 30 | ref: 0.22, // See Snips paper for explanation about this parameter 31 | preEmphasisCoefficient: 0.97, // Pre-emphasis ratio 32 | */ 33 | threshold: 0.5 // Default value 34 | }) 35 | 36 | // ***** 37 | 38 | // KEYWORD MANAGEMENT 39 | 40 | // Add a new keyword using multiple "templates" 41 | await detector.addKeyword('alexa', [ 42 | // WAV templates (trimmed with no noise!) 43 | './keywords/alexa1.wav', 44 | './keywords/alexa2.wav', 45 | './keywords/alexa3.wav' 46 | ], { 47 | // Options 48 | disableAveraging: true, // Disabled by default, disable templates averaging (note that resources consumption will increase) 49 | threshold: 0.52 // Per keyword threshold 50 | }) 51 | 52 | // Keywords can be enabled/disabled at runtime 53 | detector.disableKeyword('alexa') 54 | detector.enableKeyword('alexa') 55 | 56 | // ***** 57 | 58 | // EVENTS 59 | 60 | // The detector will emit a "ready" event when its internal audio frame buffer is filled 61 | detector.on('ready', () => { 62 | console.log('listening...') 63 | }) 64 | 65 | // The detector will emit an "error" event when it encounters an error (VAD, feature extraction, etc.) 66 | detector.on('error', err => { 67 | console.error(err.stack) 68 | }) 69 | 70 | // The detector will emit a "vad-silence" event when no voice is heard 71 | detector.on('vad-silence', () => { 72 | console.log('Hearing silence...') 73 | }) 74 | 75 | // The detector will emit a "vad-voice" event when it hears a voice 76 | detector.on('vad-voice', () => { 77 | console.log('Hearing voices...') 78 | }) 79 | 80 | // The detector will emit a "data" event when it has detected a keyword in the audio stream 81 | /* The event payload is: 82 | { 83 | "keyword" : "alexa", // The detected keyword 84 | "score" : 0.56878768987, // The detection score 85 | "threshold" : 0.5, // The detection threshold used (global or keyword) 86 | "frames" : 89, // The number of audio frames used in the detection 87 | "timestamp" : 1592574404789, // The detection timestamp (ms) 88 | "audioData" : // The utterance audio data (can be written to a file for debugging) 89 | } 90 | */ 91 | detector.on('data', ({keyword, score, threshold, timestamp}) => { 92 | console.log(`Detected "${keyword}" with score ${score} / ${threshold}`) 93 | }) 94 | 95 | // Note that as the detector is a transform stream the standard "data" event also works... 96 | // I just added the "keyword" event for clarity :) 97 | 98 | // ***** 99 | 100 | // STREAMS 101 | 102 | // As an alternative to events, the detector is a transform stream that takes audio buffers in and output keyword detection payload 103 | const detectionStream = new Stream.Writable({ 104 | objectMode: true, 105 | write: (data, enc, done) => { 106 | // `data` is equivalent to "data" event payload 107 | console.log(data) 108 | done() 109 | } 110 | }) 111 | 112 | detector.pipe(detectionStream) 113 | 114 | // ***** 115 | 116 | // Create an audio stream from an audio recorder (arecord, sox, etc.) 117 | let recorder = Mic({ 118 | channels : detector.channels, // Defaults to 1 119 | rate : detector.sampleRate, // Defaults to 16000 120 | bitwidth : detector.bitLength // Defaults to 16 121 | }) 122 | 123 | let stream = recorder.getAudioStream() 124 | 125 | // Pipe to wakeword detector 126 | stream.pipe(detector) 127 | 128 | recorder.start() 129 | 130 | // Destroy the recorder and detector after 10s 131 | setTimeout(() => { 132 | stream.unpipe(detector) 133 | stream.removeAllListeners() 134 | stream.destroy() 135 | stream = null 136 | 137 | recorder = null 138 | 139 | detector.removeAllListeners() 140 | detector.destroy() 141 | detector = null 142 | }, 10000) 143 | } 144 | 145 | main() 146 | ``` 147 | -------------------------------------------------------------------------------- /index.d.ts: -------------------------------------------------------------------------------- 1 | import type { Transform, Stream } from 'stream'; 2 | 3 | type SampleRate = 16000 | 22050 | 32000 | 44100 | 48000; 4 | type BitLength = 8 | 16 | 24 | 32; 5 | 6 | declare enum VadMode { 7 | NORMAL = 0, 8 | LOW_BITRATE = 1, 9 | AGGRESSIVE = 2, 10 | VERY_AGGRESSIVE = 3 11 | } 12 | 13 | declare type WakewordDetectorOptions = { 14 | /** 15 | * DTW window width 16 | * 17 | * @default 5 18 | */ 19 | bandSize?: number, 20 | 21 | /** 22 | * Reference distance 23 | * 24 | * @default 0.22 25 | */ 26 | ref?: number, 27 | 28 | /** 29 | * Number of input channels. 1 for mono, 2 for stereo. 30 | * 31 | * @default 1 32 | */ 33 | channels?: 1 | 2, 34 | 35 | /** 36 | * Bit depth of the input audio. 37 | * 38 | * @default 16 39 | */ 40 | bitLength?: BitLength, 41 | 42 | /** 43 | * Sample rate of the input audio. Not recommended to go over 16000 for performance reasons. 44 | * 45 | * @default 16000 46 | */ 47 | sampleRate?: SampleRate, 48 | 49 | /** 50 | * Length of each frame in milliseconds. **Must** be a multiple of `frameShiftMS`. 51 | * 52 | * @default 30 53 | */ 54 | frameLengthMS?: number, 55 | 56 | /** 57 | * @default 10 58 | */ 59 | frameShiftMS?: number, 60 | 61 | /** 62 | * The default detection threshold for new keywords. Each keyword may have their own threshold, but if no threshold 63 | * is configured for a new keyword, it will default to this. 64 | * 65 | * @default 0.5 66 | */ 67 | threshold?: number, 68 | 69 | /** 70 | * Voice activity detection mode. Only applies if `vad` is enabled. 71 | * 72 | * @default VadMode.AGGRESSIVE 73 | */ 74 | vadMode?: VadMode, 75 | 76 | /** 77 | * How much time it takes for the VAD mode to change in milliseconds. 78 | * 79 | * @default 500 80 | */ 81 | vadDebounceTime?: number, 82 | 83 | preEmphasisCoefficient?: number, 84 | 85 | /** 86 | * Whether or not to use voice activity detection. The detector will only run if there is voice activity detected. 87 | * 88 | * @default true 89 | */ 90 | vad?: boolean 91 | }; 92 | 93 | declare class WakewordDetector extends Transform { 94 | static VadMode: typeof VadMode; 95 | 96 | options: WakewordDetectorOptions; 97 | constructor(options?: WakewordDetectorOptions); 98 | 99 | /** 100 | * Whether or not the extractor is currently full. 101 | */ 102 | get full(): boolean; 103 | 104 | /** 105 | * Whether or not the detector is currently buffering. 106 | */ 107 | get buffering(): boolean; 108 | set buffering(enabled: boolean); 109 | 110 | /** 111 | * The number of channels in the input audio. 112 | */ 113 | get channels(): 1 | 2; 114 | 115 | /** 116 | * The bit depth of the input audio. 117 | */ 118 | get bitLength(): BitLength; 119 | 120 | /** 121 | * The sample rate of the input audio. 122 | */ 123 | get sampleRate(): SampleRate; 124 | 125 | /** 126 | * The numer of samples per each frame. 127 | */ 128 | get samplesPerFrame(): number; 129 | 130 | get samplesPerShift(): number; 131 | 132 | /** 133 | * The length of each frame, in milliseconds. 134 | */ 135 | get frameLengthMS(): number; 136 | 137 | get frameShiftMS(): number; 138 | 139 | /** 140 | * The default detection threshold for new keywords. Each keyword may have their own threshold, but if no threshold 141 | * is configured for a new keyword, it will default to this. 142 | */ 143 | get threshold(): number; 144 | 145 | /** 146 | * Whether or not to use voice activity detection. The detector will only run if there is voice activity detected. 147 | */ 148 | get useVad(): boolean; 149 | 150 | get vadMode(): VadMode; 151 | 152 | /** 153 | * How much time it takes for the VAD mode to change in milliseconds. 154 | */ 155 | get vadDebounceTime(): number; 156 | 157 | on(event: 'data', cb: (data: { 158 | keyword: string, 159 | score: number, 160 | threshold: number, 161 | frames: number, 162 | audioData: Buffer, 163 | timestamp: number 164 | }) => void): void; 165 | 166 | /** 167 | * Extracts features from a WAV file. 168 | * 169 | * It's assumed that the WAV file has the same sample rate, bit depth, and channels of the detector. 170 | * 171 | * @param file The path to the WAV file. 172 | */ 173 | extractFeaturesFromFile(file: string): Promise; 174 | 175 | /** 176 | * Extracts features from a PCM buffer. 177 | * 178 | * It's assumed that the audio buffer has the same sample rate, bit depth, and channels of the detector. 179 | */ 180 | extractFeaturesFromBuffer(buffer: Buffer): Promise; 181 | 182 | /** 183 | * Extracts features from a PCM stream. 184 | * 185 | * It's assumed that the audio stream has the same sample rate, bit depth, and channels of the detector. 186 | */ 187 | extractFeaturesFromStream(stream: Stream): Promise; 188 | 189 | /** 190 | * Adds a keyword to this detector. 191 | * 192 | * @param templates An array of templates, either a path to a WAV file or a PCM Buffer. 193 | */ 194 | addKeyword(keyword: string, templates: (Buffer | string)[], options?: { 195 | /** 196 | * Disable averaging the template audio. 197 | * 198 | * @default false 199 | */ 200 | disableAveraging?: boolean, 201 | 202 | /** 203 | * The threshold of this keyword. Set to 0 or `undefined` to use the detector's default threshold. 204 | * 205 | * @default 0 206 | */ 207 | threshold?: number 208 | }): Promise; 209 | 210 | /** 211 | * Removes a keyword from this detector. 212 | */ 213 | removeKeyword(keyword: string): void; 214 | 215 | /** 216 | * Clears all keywords from this detector. 217 | */ 218 | clearKeywords(): void; 219 | 220 | /** 221 | * Enable detection of a keyword. 222 | */ 223 | enableKeyword(keyword: string): void; 224 | 225 | /** 226 | * Disable detection of a keyword. 227 | */ 228 | disableKeyword(keyword: string): void; 229 | } 230 | 231 | export = WakewordDetector; 232 | -------------------------------------------------------------------------------- /index.js: -------------------------------------------------------------------------------- 1 | const Wakewordetector = require('./src/detector') 2 | 3 | module.exports = Wakewordetector -------------------------------------------------------------------------------- /package-lock.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@mathquis/node-personal-wakeword", 3 | "version": "1.1.1", 4 | "lockfileVersion": 1, 5 | "requires": true, 6 | "dependencies": { 7 | "@mapbox/node-pre-gyp": { 8 | "version": "1.0.4", 9 | "resolved": "https://registry.npmjs.org/@mapbox/node-pre-gyp/-/node-pre-gyp-1.0.4.tgz", 10 | "integrity": "sha512-M669Qo4nRT7iDmQEjQYC7RU8Z6dpz9UmSbkJ1OFEja3uevCdLKh7IZZki7L1TZj02kRyl82snXFY8QqkyfowrQ==", 11 | "requires": { 12 | "detect-libc": "^1.0.3", 13 | "https-proxy-agent": "^5.0.0", 14 | "make-dir": "^3.1.0", 15 | "node-fetch": "^2.6.1", 16 | "nopt": "^5.0.0", 17 | "npmlog": "^4.1.2", 18 | "rimraf": "^3.0.2", 19 | "semver": "^7.3.4", 20 | "tar": "^6.1.0" 21 | } 22 | }, 23 | "@mathquis/node-gist": { 24 | "version": "1.0.3", 25 | "resolved": "https://registry.npmjs.org/@mathquis/node-gist/-/node-gist-1.0.3.tgz", 26 | "integrity": "sha512-tFsQtGmCtr4mv+o5xTquMWRMaKuCcY2Mw5vECakAOpJNuGwxsh5P37jM5p5M9kHkYvZvRHVrEa+1hAQWcOeByA==", 27 | "requires": { 28 | "@mapbox/node-pre-gyp": "^1.0.4", 29 | "node-addon-api": "^2.0.0" 30 | } 31 | }, 32 | "abbrev": { 33 | "version": "1.1.1", 34 | "resolved": "https://registry.npmjs.org/abbrev/-/abbrev-1.1.1.tgz", 35 | "integrity": "sha512-nne9/IiQ/hzIhY6pdDnbBtz7DjPTKrY00P/zvPSm5pOFkl6xuGrGnXn/VtTNNfNtAfZ9/1RtehkszU9qcTii0Q==" 36 | }, 37 | "agent-base": { 38 | "version": "6.0.2", 39 | "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-6.0.2.tgz", 40 | "integrity": "sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==", 41 | "requires": { 42 | "debug": "4" 43 | } 44 | }, 45 | "ansi-regex": { 46 | "version": "2.1.1", 47 | "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-2.1.1.tgz", 48 | "integrity": "sha1-w7M6te42DYbg5ijwRorn7yfWVN8=" 49 | }, 50 | "ansi-styles": { 51 | "version": "4.3.0", 52 | "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", 53 | "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", 54 | "dev": true, 55 | "requires": { 56 | "color-convert": "^2.0.1" 57 | } 58 | }, 59 | "aproba": { 60 | "version": "1.2.0", 61 | "resolved": "https://registry.npmjs.org/aproba/-/aproba-1.2.0.tgz", 62 | "integrity": "sha512-Y9J6ZjXtoYh8RnXVCMOU/ttDmk1aBjunq9vO0ta5x85WDQiQfUF9sIPBITdbiiIVcBo03Hi3jMxigBtsddlXRw==" 63 | }, 64 | "are-we-there-yet": { 65 | "version": "1.1.5", 66 | "resolved": "https://registry.npmjs.org/are-we-there-yet/-/are-we-there-yet-1.1.5.tgz", 67 | "integrity": "sha512-5hYdAkZlcG8tOLujVDTgCT+uPX0VnpAH28gWsLfzpXYm7wP6mp5Q/gYyR7YQ0cKVJcXJnl3j2kpBan13PtQf6w==", 68 | "requires": { 69 | "delegates": "^1.0.0", 70 | "readable-stream": "^2.0.6" 71 | }, 72 | "dependencies": { 73 | "readable-stream": { 74 | "version": "2.3.7", 75 | "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.7.tgz", 76 | "integrity": "sha512-Ebho8K4jIbHAxnuxi7o42OrZgF/ZTNcsZj6nRKyUmkhLFq8CHItp/fy6hQZuZmP/n3yZ9VBUbp4zz/mX8hmYPw==", 77 | "requires": { 78 | "core-util-is": "~1.0.0", 79 | "inherits": "~2.0.3", 80 | "isarray": "~1.0.0", 81 | "process-nextick-args": "~2.0.0", 82 | "safe-buffer": "~5.1.1", 83 | "string_decoder": "~1.1.1", 84 | "util-deprecate": "~1.0.1" 85 | } 86 | }, 87 | "safe-buffer": { 88 | "version": "5.1.2", 89 | "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", 90 | "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==" 91 | }, 92 | "string_decoder": { 93 | "version": "1.1.1", 94 | "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz", 95 | "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==", 96 | "requires": { 97 | "safe-buffer": "~5.1.0" 98 | } 99 | } 100 | } 101 | }, 102 | "balanced-match": { 103 | "version": "1.0.2", 104 | "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", 105 | "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==" 106 | }, 107 | "bindings": { 108 | "version": "1.5.0", 109 | "resolved": "https://registry.npmjs.org/bindings/-/bindings-1.5.0.tgz", 110 | "integrity": "sha512-p2q/t/mhvuOj/UeLlV6566GD/guowlr0hHxClI0W9m7MWYkL1F0hLo+0Aexs9HSPCtR1SXQ0TD3MMKrXZajbiQ==", 111 | "requires": { 112 | "file-uri-to-path": "1.0.0" 113 | } 114 | }, 115 | "block-stream2": { 116 | "version": "2.1.0", 117 | "resolved": "https://registry.npmjs.org/block-stream2/-/block-stream2-2.1.0.tgz", 118 | "integrity": "sha512-suhjmLI57Ewpmq00qaygS8UgEq2ly2PCItenIyhMqVjo4t4pGzqMvfgJuX8iWTeSDdfSSqS6j38fL4ToNL7Pfg==", 119 | "requires": { 120 | "readable-stream": "^3.4.0" 121 | } 122 | }, 123 | "brace-expansion": { 124 | "version": "1.1.11", 125 | "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz", 126 | "integrity": "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==", 127 | "requires": { 128 | "balanced-match": "^1.0.0", 129 | "concat-map": "0.0.1" 130 | } 131 | }, 132 | "chalk": { 133 | "version": "4.1.1", 134 | "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.1.tgz", 135 | "integrity": "sha512-diHzdDKxcU+bAsUboHLPEDQiw0qEe0qd7SYUn3HgcFlWgbDcfLGswOHYeGrHKzG9z6UYf01d9VFMfZxPM1xZSg==", 136 | "dev": true, 137 | "requires": { 138 | "ansi-styles": "^4.1.0", 139 | "supports-color": "^7.1.0" 140 | } 141 | }, 142 | "chownr": { 143 | "version": "2.0.0", 144 | "resolved": "https://registry.npmjs.org/chownr/-/chownr-2.0.0.tgz", 145 | "integrity": "sha512-bIomtDF5KGpdogkLd9VspvFzk9KfpyyGlS8YFVZl7TGPBHL5snIOnxeshwVgPteQ9b4Eydl+pVbIyE1DcvCWgQ==" 146 | }, 147 | "code-point-at": { 148 | "version": "1.1.0", 149 | "resolved": "https://registry.npmjs.org/code-point-at/-/code-point-at-1.1.0.tgz", 150 | "integrity": "sha1-DQcLTQQ6W+ozovGkDi7bPZpMz3c=" 151 | }, 152 | "color-convert": { 153 | "version": "2.0.1", 154 | "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", 155 | "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", 156 | "dev": true, 157 | "requires": { 158 | "color-name": "~1.1.4" 159 | } 160 | }, 161 | "color-name": { 162 | "version": "1.1.4", 163 | "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", 164 | "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", 165 | "dev": true 166 | }, 167 | "concat-map": { 168 | "version": "0.0.1", 169 | "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", 170 | "integrity": "sha1-2Klr13/Wjfd5OnMDajug1UBdR3s=" 171 | }, 172 | "console-control-strings": { 173 | "version": "1.1.0", 174 | "resolved": "https://registry.npmjs.org/console-control-strings/-/console-control-strings-1.1.0.tgz", 175 | "integrity": "sha1-PXz0Rk22RG6mRL9LOVB/mFEAjo4=" 176 | }, 177 | "core-util-is": { 178 | "version": "1.0.2", 179 | "resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.2.tgz", 180 | "integrity": "sha1-tf1UIgqivFq1eqtxQMlAdUUDwac=" 181 | }, 182 | "debug": { 183 | "version": "4.3.1", 184 | "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.1.tgz", 185 | "integrity": "sha512-doEwdvm4PCeK4K3RQN2ZC2BYUBaxwLARCqZmMjtF8a51J2Rb0xpVloFRnCODwqjpwnAoao4pelN8l3RJdv3gRQ==", 186 | "requires": { 187 | "ms": "2.1.2" 188 | } 189 | }, 190 | "delegates": { 191 | "version": "1.0.0", 192 | "resolved": "https://registry.npmjs.org/delegates/-/delegates-1.0.0.tgz", 193 | "integrity": "sha1-hMbhWbgZBP3KWaDvRM2HDTElD5o=" 194 | }, 195 | "detect-libc": { 196 | "version": "1.0.3", 197 | "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-1.0.3.tgz", 198 | "integrity": "sha1-+hN8S9aY7fVc1c0CrFWfkaTEups=" 199 | }, 200 | "file-uri-to-path": { 201 | "version": "1.0.0", 202 | "resolved": "https://registry.npmjs.org/file-uri-to-path/-/file-uri-to-path-1.0.0.tgz", 203 | "integrity": "sha512-0Zt+s3L7Vf1biwWZ29aARiVYLx7iMGnEUl9x33fbB/j3jR81u/O2LbqK+Bm1CDSNDKVtJ/YjwY7TUd5SkeLQLw==" 204 | }, 205 | "fs-minipass": { 206 | "version": "2.1.0", 207 | "resolved": "https://registry.npmjs.org/fs-minipass/-/fs-minipass-2.1.0.tgz", 208 | "integrity": "sha512-V/JgOLFCS+R6Vcq0slCuaeWEdNC3ouDlJMNIsacH2VtALiu9mV4LPrHc5cDl8k5aw6J8jwgWWpiTo5RYhmIzvg==", 209 | "requires": { 210 | "minipass": "^3.0.0" 211 | } 212 | }, 213 | "fs.realpath": { 214 | "version": "1.0.0", 215 | "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz", 216 | "integrity": "sha1-FQStJSMVjKpA20onh8sBQRmU6k8=" 217 | }, 218 | "gauge": { 219 | "version": "2.7.4", 220 | "resolved": "https://registry.npmjs.org/gauge/-/gauge-2.7.4.tgz", 221 | "integrity": "sha1-LANAXHU4w51+s3sxcCLjJfsBi/c=", 222 | "requires": { 223 | "aproba": "^1.0.3", 224 | "console-control-strings": "^1.0.0", 225 | "has-unicode": "^2.0.0", 226 | "object-assign": "^4.1.0", 227 | "signal-exit": "^3.0.0", 228 | "string-width": "^1.0.1", 229 | "strip-ansi": "^3.0.1", 230 | "wide-align": "^1.1.0" 231 | } 232 | }, 233 | "glob": { 234 | "version": "7.1.6", 235 | "resolved": "https://registry.npmjs.org/glob/-/glob-7.1.6.tgz", 236 | "integrity": "sha512-LwaxwyZ72Lk7vZINtNNrywX0ZuLyStrdDtabefZKAY5ZGJhVtgdznluResxNmPitE0SAO+O26sWTHeKSI2wMBA==", 237 | "requires": { 238 | "fs.realpath": "^1.0.0", 239 | "inflight": "^1.0.4", 240 | "inherits": "2", 241 | "minimatch": "^3.0.4", 242 | "once": "^1.3.0", 243 | "path-is-absolute": "^1.0.0" 244 | } 245 | }, 246 | "has-flag": { 247 | "version": "4.0.0", 248 | "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", 249 | "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", 250 | "dev": true 251 | }, 252 | "has-unicode": { 253 | "version": "2.0.1", 254 | "resolved": "https://registry.npmjs.org/has-unicode/-/has-unicode-2.0.1.tgz", 255 | "integrity": "sha1-4Ob+aijPUROIVeCG0Wkedx3iqLk=" 256 | }, 257 | "https-proxy-agent": { 258 | "version": "5.0.0", 259 | "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-5.0.0.tgz", 260 | "integrity": "sha512-EkYm5BcKUGiduxzSt3Eppko+PiNWNEpa4ySk9vTC6wDsQJW9rHSa+UhGNJoRYp7bz6Ht1eaRIa6QaJqO5rCFbA==", 261 | "requires": { 262 | "agent-base": "6", 263 | "debug": "4" 264 | } 265 | }, 266 | "inflight": { 267 | "version": "1.0.6", 268 | "resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz", 269 | "integrity": "sha1-Sb1jMdfQLQwJvJEKEHW6gWW1bfk=", 270 | "requires": { 271 | "once": "^1.3.0", 272 | "wrappy": "1" 273 | } 274 | }, 275 | "inherits": { 276 | "version": "2.0.4", 277 | "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", 278 | "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==" 279 | }, 280 | "is-fullwidth-code-point": { 281 | "version": "1.0.0", 282 | "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-1.0.0.tgz", 283 | "integrity": "sha1-754xOG8DGn8NZDr4L95QxFfvAMs=", 284 | "requires": { 285 | "number-is-nan": "^1.0.0" 286 | } 287 | }, 288 | "isarray": { 289 | "version": "1.0.0", 290 | "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz", 291 | "integrity": "sha1-u5NdSFgsuhaMBoNJV6VKPgcSTxE=" 292 | }, 293 | "lru-cache": { 294 | "version": "6.0.0", 295 | "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz", 296 | "integrity": "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==", 297 | "requires": { 298 | "yallist": "^4.0.0" 299 | } 300 | }, 301 | "make-dir": { 302 | "version": "3.1.0", 303 | "resolved": "https://registry.npmjs.org/make-dir/-/make-dir-3.1.0.tgz", 304 | "integrity": "sha512-g3FeP20LNwhALb/6Cz6Dd4F2ngze0jz7tbzrD2wAV+o9FeNHe4rL+yK2md0J/fiSf1sa1ADhXqi5+oVwOM/eGw==", 305 | "requires": { 306 | "semver": "^6.0.0" 307 | }, 308 | "dependencies": { 309 | "semver": { 310 | "version": "6.3.0", 311 | "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.0.tgz", 312 | "integrity": "sha512-b39TBaTSfV6yBrapU89p5fKekE2m/NwnDocOVruQFS1/veMgdzuPcnOM34M6CwxW8jH/lxEa5rBoDeUwu5HHTw==" 313 | } 314 | } 315 | }, 316 | "minimatch": { 317 | "version": "3.1.2", 318 | "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", 319 | "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", 320 | "requires": { 321 | "brace-expansion": "^1.1.7" 322 | } 323 | }, 324 | "minipass": { 325 | "version": "3.1.3", 326 | "resolved": "https://registry.npmjs.org/minipass/-/minipass-3.1.3.tgz", 327 | "integrity": "sha512-Mgd2GdMVzY+x3IJ+oHnVM+KG3lA5c8tnabyJKmHSaG2kAGpudxuOf8ToDkhumF7UzME7DecbQE9uOZhNm7PuJg==", 328 | "requires": { 329 | "yallist": "^4.0.0" 330 | } 331 | }, 332 | "minizlib": { 333 | "version": "2.1.2", 334 | "resolved": "https://registry.npmjs.org/minizlib/-/minizlib-2.1.2.tgz", 335 | "integrity": "sha512-bAxsR8BVfj60DWXHE3u30oHzfl4G7khkSuPW+qvpd7jFRHm7dLxOjUk1EHACJ/hxLY8phGJ0YhYHZo7jil7Qdg==", 336 | "requires": { 337 | "minipass": "^3.0.0", 338 | "yallist": "^4.0.0" 339 | } 340 | }, 341 | "mkdirp": { 342 | "version": "1.0.4", 343 | "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-1.0.4.tgz", 344 | "integrity": "sha512-vVqVZQyf3WLx2Shd0qJ9xuvqgAyKPLAiqITEtqW0oIUjzo3PePDd6fW9iFz30ef7Ysp/oiWqbhszeGWW2T6Gzw==" 345 | }, 346 | "ms": { 347 | "version": "2.1.2", 348 | "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz", 349 | "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==" 350 | }, 351 | "nan": { 352 | "version": "2.14.2", 353 | "resolved": "https://registry.npmjs.org/nan/-/nan-2.14.2.tgz", 354 | "integrity": "sha512-M2ufzIiINKCuDfBSAUr1vWQ+vuVcA9kqx8JJUsbQi6yf1uGRyb7HfpdfUr5qLXf3B/t8dPvcjhKMmlfnP47EzQ==" 355 | }, 356 | "node-addon-api": { 357 | "version": "2.0.2", 358 | "resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-2.0.2.tgz", 359 | "integrity": "sha512-Ntyt4AIXyaLIuMHF6IOoTakB3K+RWxwtsHNRxllEoA6vPwP9o4866g6YWDLUdnucilZhmkxiHwHr11gAENw+QA==" 360 | }, 361 | "node-fetch": { 362 | "version": "2.6.7", 363 | "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.6.7.tgz", 364 | "integrity": "sha512-ZjMPFEfVx5j+y2yF35Kzx5sF7kDzxuDj6ziH4FFbOp87zKDZNx8yExJIb05OGF4Nlt9IHFIMBkRl41VdvcNdbQ==", 365 | "requires": { 366 | "whatwg-url": "^5.0.0" 367 | } 368 | }, 369 | "node-vad": { 370 | "version": "1.1.4", 371 | "resolved": "https://registry.npmjs.org/node-vad/-/node-vad-1.1.4.tgz", 372 | "integrity": "sha512-iz9riP5DMvN2rQSmeFfJL6jp4jbHE7opRQdQcMk/HqfphXiRRVKb0G+ZKtpnyrQqIXIH/UuXXK0OU1aRmwQrkg==", 373 | "requires": { 374 | "bindings": "^1.5.0", 375 | "nan": "^2.14.0", 376 | "util-promisifyall": "^1.0.6" 377 | } 378 | }, 379 | "nopt": { 380 | "version": "5.0.0", 381 | "resolved": "https://registry.npmjs.org/nopt/-/nopt-5.0.0.tgz", 382 | "integrity": "sha512-Tbj67rffqceeLpcRXrT7vKAN8CwfPeIBgM7E6iBkmKLV7bEMwpGgYLGv0jACUsECaa/vuxP0IjEont6umdMgtQ==", 383 | "requires": { 384 | "abbrev": "1" 385 | } 386 | }, 387 | "npmlog": { 388 | "version": "4.1.2", 389 | "resolved": "https://registry.npmjs.org/npmlog/-/npmlog-4.1.2.tgz", 390 | "integrity": "sha512-2uUqazuKlTaSI/dC8AzicUck7+IrEaOnN/e0jd3Xtt1KcGpwx30v50mL7oPyr/h9bL3E4aZccVwpwP+5W9Vjkg==", 391 | "requires": { 392 | "are-we-there-yet": "~1.1.2", 393 | "console-control-strings": "~1.1.0", 394 | "gauge": "~2.7.3", 395 | "set-blocking": "~2.0.0" 396 | } 397 | }, 398 | "number-is-nan": { 399 | "version": "1.0.1", 400 | "resolved": "https://registry.npmjs.org/number-is-nan/-/number-is-nan-1.0.1.tgz", 401 | "integrity": "sha1-CXtgK1NCKlIsGvuHkDGDNpQaAR0=" 402 | }, 403 | "object-assign": { 404 | "version": "4.1.1", 405 | "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", 406 | "integrity": "sha1-IQmtx5ZYh8/AXLvUQsrIv7s2CGM=" 407 | }, 408 | "once": { 409 | "version": "1.4.0", 410 | "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", 411 | "integrity": "sha1-WDsap3WWHUsROsF9nFC6753Xa9E=", 412 | "requires": { 413 | "wrappy": "1" 414 | } 415 | }, 416 | "path-is-absolute": { 417 | "version": "1.0.1", 418 | "resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz", 419 | "integrity": "sha1-F0uSaHNVNP+8es5r9TpanhtcX18=" 420 | }, 421 | "process-nextick-args": { 422 | "version": "2.0.1", 423 | "resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.1.tgz", 424 | "integrity": "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==" 425 | }, 426 | "readable-stream": { 427 | "version": "3.6.0", 428 | "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.0.tgz", 429 | "integrity": "sha512-BViHy7LKeTz4oNnkcLJ+lVSL6vpiFeX6/d3oSH8zCW7UxP2onchk+vTGB143xuFjHS3deTgkKoXXymXqymiIdA==", 430 | "requires": { 431 | "inherits": "^2.0.3", 432 | "string_decoder": "^1.1.1", 433 | "util-deprecate": "^1.0.1" 434 | } 435 | }, 436 | "rimraf": { 437 | "version": "3.0.2", 438 | "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-3.0.2.tgz", 439 | "integrity": "sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA==", 440 | "requires": { 441 | "glob": "^7.1.3" 442 | } 443 | }, 444 | "safe-buffer": { 445 | "version": "5.2.1", 446 | "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", 447 | "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==" 448 | }, 449 | "semver": { 450 | "version": "7.3.5", 451 | "resolved": "https://registry.npmjs.org/semver/-/semver-7.3.5.tgz", 452 | "integrity": "sha512-PoeGJYh8HK4BTO/a9Tf6ZG3veo/A7ZVsYrSA6J8ny9nb3B1VrpkuN+z9OE5wfE5p6H4LchYZsegiQgbJD94ZFQ==", 453 | "requires": { 454 | "lru-cache": "^6.0.0" 455 | } 456 | }, 457 | "set-blocking": { 458 | "version": "2.0.0", 459 | "resolved": "https://registry.npmjs.org/set-blocking/-/set-blocking-2.0.0.tgz", 460 | "integrity": "sha1-BF+XgtARrppoA93TgrJDkrPYkPc=" 461 | }, 462 | "signal-exit": { 463 | "version": "3.0.3", 464 | "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.3.tgz", 465 | "integrity": "sha512-VUJ49FC8U1OxwZLxIbTTrDvLnf/6TDgxZcK8wxR8zs13xpx7xbG60ndBlhNrFi2EMuFRoeDoJO7wthSLq42EjA==" 466 | }, 467 | "string-width": { 468 | "version": "1.0.2", 469 | "resolved": "https://registry.npmjs.org/string-width/-/string-width-1.0.2.tgz", 470 | "integrity": "sha1-EYvfW4zcUaKn5w0hHgfisLmxB9M=", 471 | "requires": { 472 | "code-point-at": "^1.0.0", 473 | "is-fullwidth-code-point": "^1.0.0", 474 | "strip-ansi": "^3.0.0" 475 | } 476 | }, 477 | "string_decoder": { 478 | "version": "1.3.0", 479 | "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz", 480 | "integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==", 481 | "requires": { 482 | "safe-buffer": "~5.2.0" 483 | } 484 | }, 485 | "strip-ansi": { 486 | "version": "3.0.1", 487 | "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-3.0.1.tgz", 488 | "integrity": "sha1-ajhfuIU9lS1f8F0Oiq+UJ43GPc8=", 489 | "requires": { 490 | "ansi-regex": "^2.0.0" 491 | } 492 | }, 493 | "supports-color": { 494 | "version": "7.2.0", 495 | "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", 496 | "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", 497 | "dev": true, 498 | "requires": { 499 | "has-flag": "^4.0.0" 500 | } 501 | }, 502 | "tar": { 503 | "version": "6.1.11", 504 | "resolved": "https://registry.npmjs.org/tar/-/tar-6.1.11.tgz", 505 | "integrity": "sha512-an/KZQzQUkZCkuoAA64hM92X0Urb6VpRhAFllDzz44U2mcD5scmT3zBc4VgVpkugF580+DQn8eAFSyoQt0tznA==", 506 | "requires": { 507 | "chownr": "^2.0.0", 508 | "fs-minipass": "^2.0.0", 509 | "minipass": "^3.0.0", 510 | "minizlib": "^2.1.1", 511 | "mkdirp": "^1.0.3", 512 | "yallist": "^4.0.0" 513 | } 514 | }, 515 | "tr46": { 516 | "version": "0.0.3", 517 | "resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz", 518 | "integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==" 519 | }, 520 | "util-deprecate": { 521 | "version": "1.0.2", 522 | "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", 523 | "integrity": "sha1-RQ1Nyfpw3nMnYvvS1KKJgUGaDM8=" 524 | }, 525 | "util-promisifyall": { 526 | "version": "1.0.6", 527 | "resolved": "https://registry.npmjs.org/util-promisifyall/-/util-promisifyall-1.0.6.tgz", 528 | "integrity": "sha512-l+o62sbaqStC1xt7oEhlafC4jWBgkOjBXvlPwxkvOYmNqpY8dNXuKdOa+VHjkYz2Fw98e0HvJtNKUg0+6hfP2w==" 529 | }, 530 | "wavefile": { 531 | "version": "11.0.0", 532 | "resolved": "https://registry.npmjs.org/wavefile/-/wavefile-11.0.0.tgz", 533 | "integrity": "sha512-/OBiAALgWU24IG7sC84cDO/KfFuvajWc5Uec0oV2zrpOOZZDgGdOwHwgEzOrwh8jkubBk7PtZfQBIcI1OaE5Ng==", 534 | "dev": true 535 | }, 536 | "webidl-conversions": { 537 | "version": "3.0.1", 538 | "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz", 539 | "integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==" 540 | }, 541 | "whatwg-url": { 542 | "version": "5.0.0", 543 | "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz", 544 | "integrity": "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==", 545 | "requires": { 546 | "tr46": "~0.0.3", 547 | "webidl-conversions": "^3.0.0" 548 | } 549 | }, 550 | "wide-align": { 551 | "version": "1.1.3", 552 | "resolved": "https://registry.npmjs.org/wide-align/-/wide-align-1.1.3.tgz", 553 | "integrity": "sha512-QGkOQc8XL6Bt5PwnsExKBPuMKBxnGxWWW3fU55Xt4feHozMUhdUMaBCk290qpm/wG5u/RSKzwdAC4i51YigihA==", 554 | "requires": { 555 | "string-width": "^1.0.2 || 2" 556 | } 557 | }, 558 | "wrappy": { 559 | "version": "1.0.2", 560 | "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", 561 | "integrity": "sha1-tSQ9jz7BqjXxNkYFvA0QNuMKtp8=" 562 | }, 563 | "yallist": { 564 | "version": "4.0.0", 565 | "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", 566 | "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==" 567 | } 568 | } 569 | } 570 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@mathquis/node-personal-wakeword", 3 | "version": "1.1.1", 4 | "description": "Personal wake word detector", 5 | "main": "index.js", 6 | "typings": "index.d.ts", 7 | "scripts": { 8 | "test": "echo \"Error: no test specified\" && exit 1" 9 | }, 10 | "dependencies": { 11 | "@mathquis/node-gist": "^1.0.3", 12 | "block-stream2": "^2.0.0", 13 | "debug": "^4.3.1", 14 | "node-vad": "^1.1.4" 15 | }, 16 | "devDependencies": { 17 | "chalk": "^4.0.0", 18 | "glob": "^7.1.6", 19 | "wavefile": "^11.0.0" 20 | }, 21 | "keywords": [ 22 | "wakeword", 23 | "mfcc", 24 | "dtw", 25 | "hotword", 26 | "detector", 27 | "node", 28 | "alexa" 29 | ], 30 | "repository": { 31 | "type": "git", 32 | "url": "git+https://github.com/mathquis/node-personal-wakeword.git" 33 | }, 34 | "author": "Mathieu \"Fastjack\" Quisefit", 35 | "license": "ISC", 36 | "bugs": { 37 | "url": "https://github.com/mathquis/node-personal-wakeword/issues" 38 | }, 39 | "homepage": "https://github.com/mathquis/node-personal-wakeword#readme" 40 | } 41 | -------------------------------------------------------------------------------- /src/comparator.js: -------------------------------------------------------------------------------- 1 | const DTW = require('./dtw/dtw') 2 | const Utils = require('./utils') 3 | 4 | class FeatureComparator { 5 | constructor(options) { 6 | this.options = options || {} 7 | this._dtw = new DTW({distanceFunction: FeatureComparator.calculateDistance}) 8 | } 9 | 10 | static calculateDistance(ax, bx) { 11 | return 1 - Utils.cosineSimilarity(ax, bx) 12 | } 13 | 14 | get bandSize() { 15 | return this.options.bandSize || 5 16 | } 17 | 18 | get ref() { 19 | return this.options.ref || 0.22 20 | } 21 | 22 | compare(a, b) { 23 | const cost = this._dtw.compute(a, b, this.bandSize) 24 | const normalizedCost = cost / ( a.length + b.length ) 25 | return this.computeProbability(normalizedCost) 26 | } 27 | 28 | computeProbability(cost) { 29 | return 1 / ( 1 + Math.exp( ( cost - this.ref ) / this.ref ) ) 30 | } 31 | 32 | destroy() { 33 | 34 | } 35 | } 36 | 37 | module.exports = FeatureComparator -------------------------------------------------------------------------------- /src/detector.js: -------------------------------------------------------------------------------- 1 | const Stream = require('stream') 2 | const File = require('fs') 3 | const Path = require('path') 4 | const debug = require('debug')('detector') 5 | const debugDetection = require('debug')('detected') 6 | const FeatureExtractor = require('./extractor') 7 | const FeatureComparator = require('./comparator') 8 | const WakewordKeyword = require('./keyword') 9 | const VoiceActivityFilter = require('./vad') 10 | 11 | class WakewordDetector extends Stream.Transform { 12 | constructor(options) { 13 | // Take audio buffer in and output keyword detection payload 14 | super({ 15 | readableObjectMode: true 16 | }) 17 | 18 | this.options = options || {} 19 | this._keywords = new Map() 20 | this._buffering = true 21 | this._full = false 22 | this._minFrames = 9999 23 | this._maxFrames = 0 24 | 25 | debug('sampleRate : %d', this.sampleRate) 26 | debug('bitLength : %d', this.bitLength) 27 | debug('channels : %d', this.channels) 28 | debug('samplesPerFrame : %d', this.samplesPerFrame) 29 | debug('samplesPerShift : %d', this.samplesPerShift) 30 | debug('frameLengthMS : %d', this.frameLengthMS) 31 | debug('frameShiftMS : %d', this.frameShiftMS) 32 | debug('threshold : %d', this.threshold) 33 | debug('useVad : %s', this.useVad) 34 | debug('vadMode : %s', this.vadMode) 35 | debug('vadDebounceTime : %d', this.vadDebounceTime) 36 | 37 | this._comparator = new FeatureComparator(options) 38 | 39 | this._extractor = this._createExtractor() 40 | 41 | this._extractor 42 | .on('data', ({features, audioBuffer}) => { 43 | this._processFeatures(features, audioBuffer) 44 | }) 45 | .on('error', err => { 46 | this.error(err) 47 | }) 48 | .on('drain', () => { 49 | debug('Extractor is available') 50 | this._full = false 51 | }) 52 | 53 | this._vad = new VoiceActivityFilter({ 54 | buffering: true, 55 | sampleRate: this.sampleRate, 56 | vadDebounceTime: this.vadDebounceTime 57 | }) 58 | 59 | this.clearKeywords() 60 | this.reset() 61 | } 62 | 63 | get full() { 64 | return this._full 65 | } 66 | 67 | get buffering() { 68 | return this._buffering 69 | } 70 | 71 | set buffering(enabled) { 72 | this._buffering = !! enabled 73 | } 74 | 75 | get channels() { 76 | return this.options.channels || 1 77 | } 78 | 79 | get bitLength() { 80 | return this.options.bitLength || 16 81 | } 82 | 83 | get sampleRate() { 84 | return this.options.sampleRate || 16000 85 | } 86 | 87 | get samplesPerFrame() { 88 | return this.sampleRate * this.frameLengthMS / 1000 89 | } 90 | 91 | get samplesPerShift() { 92 | return this.sampleRate * this.frameShiftMS / 1000 93 | } 94 | 95 | get frameLengthMS() { 96 | return this.options.frameLengthMS || 30.0 97 | } 98 | 99 | get frameShiftMS() { 100 | return this.options.frameShiftMS || 10.0 101 | } 102 | 103 | get threshold() { 104 | const threshold = parseFloat(this.options.threshold) 105 | if ( isNaN(threshold) ) return 0.5 106 | return threshold 107 | } 108 | 109 | get useVad() { 110 | return typeof this.options.vad !== 'undefined' ? this.options.vad : true 111 | } 112 | 113 | get vadMode() { 114 | return this.options.vadMode || VoiceActivityFilter.Mode.AGGRESSIVE 115 | } 116 | 117 | get vadDebounceTime() { 118 | return this.options.vadDebounceTime || 500 119 | } 120 | 121 | async extractFeaturesFromFile(file) { 122 | const filePath = Path.resolve( process.cwd(), file ) 123 | debug('Extracting features from file "%s"', filePath) 124 | let stats 125 | try { 126 | stats = await File.promises.stat(filePath) 127 | } catch (err) { 128 | throw new Error(`File "${filePath}" not found`) 129 | } 130 | if ( !stats.isFile() ) { 131 | throw new Error(`File "${filePath}" is not a file`) 132 | } 133 | const input = File.createReadStream( filePath, {start: 44} ) 134 | return await this.extractFeaturesFromStream(input) 135 | } 136 | 137 | async extractFeaturesFromBuffer(buffer) { 138 | debug('Extracting features from buffer (length: %d)', buffer.length) 139 | const reader = new Stream.Readable({ 140 | read: () => {} 141 | }) 142 | 143 | reader.push(buffer) 144 | reader.push(null) 145 | 146 | return await this.extractFeaturesFromStream(reader) 147 | } 148 | 149 | async extractFeaturesFromStream(input) { 150 | debug('Extracting features from stream') 151 | const frames = await new Promise(async (resolve, reject) => { 152 | const frames = [] 153 | const extractor = this._createExtractor() 154 | extractor.on('data', ({features}) => { 155 | frames.push(features) 156 | }) 157 | input 158 | .on('error', err => { 159 | reject(err) 160 | }) 161 | .on('end', () => { 162 | resolve( this._normalizeFeatures(frames) ) 163 | }) 164 | 165 | input.pipe(extractor) 166 | }) 167 | const firstFrame = frames[0] || [] 168 | debug('Features: %d x %d', frames.length, firstFrame.length) 169 | return frames 170 | } 171 | 172 | async addKeyword(keyword, templates, options) { 173 | if ( this.destroyed ) throw new Error('Unable to add keyword') 174 | let kw = this._keywords.get(keyword) 175 | if ( !kw ) { 176 | kw = new WakewordKeyword(keyword, options) 177 | this._keywords.set(keyword, kw) 178 | } 179 | await Promise.all( 180 | templates.map(async template => { 181 | let features 182 | if ( Buffer.isBuffer(template) ) { 183 | features = await this.extractFeaturesFromBuffer(template) 184 | } else { 185 | features = await this.extractFeaturesFromFile(template) 186 | } 187 | this._minFrames = Math.min(this._minFrames, features.length) 188 | this._maxFrames = Math.max(this._maxFrames, features.length) 189 | kw.addFeatures(features) 190 | }) 191 | ) 192 | debug('Added keyword "%s" (templates: %d)', keyword, templates.length) 193 | } 194 | 195 | removeKeyword(keyword) { 196 | if ( this.destroyed ) throw new Error('Unable to remove keyword') 197 | this._keywords.delete(keyword) 198 | debug('Removed keyword "%s"', keyword) 199 | } 200 | 201 | clearKeywords() { 202 | this._keywords = new Map() 203 | debug('Keywords cleared') 204 | } 205 | 206 | enableKeyword(keyword) { 207 | if ( this.destroyed ) throw new Error('Unable to enable keyword') 208 | const kw = this._keywords.get(keyword) 209 | if ( !kw ) throw new Error(`Unknown keyword "${keyword}"`) 210 | kw.enabled = true 211 | debug('Keyword "%s" enabled', keyword) 212 | } 213 | 214 | disableKeyword(keyword) { 215 | if ( this.destroyed ) throw new Error('Unable to disable keyword') 216 | const kw = this._keywords.get(keyword) 217 | if ( !kw ) throw new Error(`Unknown keyword "${keyword}"`) 218 | kw.enabled = false 219 | debug('Keyword "%s" disabled', keyword) 220 | } 221 | 222 | async match(audioData) { 223 | const st = (new Date()).getTime() 224 | const frames = await this.extractFeaturesFromBuffer(audioData) 225 | const features = this._normalizeFeatures(frames) 226 | const result = this._getBestKeyword(features) 227 | if ( result.keyword !== null ) { 228 | const timestamp = (new Date()).getTime() 229 | const et = (new Date()).getTime() 230 | const match = { 231 | ...result, 232 | score: result.score, 233 | duration: (et-st) 234 | } 235 | return match 236 | } 237 | return null 238 | } 239 | 240 | process(audioBuffer) { 241 | if ( this.destroyed ) throw new Error('Unable to process audio buffer with destroyed stream') 242 | this.write(audioBuffer) 243 | } 244 | 245 | reset() { 246 | this._frames = [] 247 | this._chunks = [] 248 | this._state = {keyword: null, score: 0} 249 | this.buffering = true 250 | debug('Reset') 251 | debug('destroyed', this.destroyed) 252 | debug('writable.writable', this.writable) 253 | debug('writable.writableEnded', this.writableEnded) 254 | debug('writable.writableCorked', this.writableCorked) 255 | debug('writable.writableFinished', this.writableFinished) 256 | debug('writable.writableHighWaterMark', this.writableHighWaterMark) 257 | debug('writable.writableLength', this.writableLength) 258 | debug('writable.writableNeedDrain', this.writableNeedDrain) 259 | debug('readable.readable', this.readable) 260 | debug('readable.readableEncoding', this.readableEncoding) 261 | debug('readable.readableEnded', this.readableEnded) 262 | debug('readable.readableFlowing', this.readableFlowing) 263 | debug('readable.readableHighWaterMark', this.readableHighWaterMark) 264 | debug('readable.readableLength', this.readableLength) 265 | debug('readable.readableObjectMode', this.readableObjectMode) 266 | } 267 | 268 | error(err) { 269 | this.emit('error', err) 270 | } 271 | 272 | destroy(err) { 273 | this._vad.destroy() 274 | this._vad = null 275 | 276 | this._extractor.removeAllListeners() 277 | this._extractor.destroy() 278 | this._extractor = null 279 | 280 | this._comparator.destroy() 281 | this._comparator = null 282 | 283 | this.clearKeywords() 284 | this.reset() 285 | 286 | super.destroy(err) 287 | 288 | debug('Destroyed') 289 | } 290 | 291 | async _transform(buffer, enc, done) { 292 | if ( this._keywords.size === 0 ) { 293 | done() 294 | return 295 | } 296 | if ( this.full ) { 297 | done() 298 | return 299 | } 300 | if ( this._extractor.full ) { 301 | done() 302 | return 303 | } 304 | let isVoice = true 305 | if ( this.useVad ) { 306 | isVoice = await this._vad.processAudio(buffer) 307 | debug('Voice? %s', isVoice) 308 | } 309 | if ( !isVoice ) { 310 | done() 311 | return 312 | } 313 | debug('Piping buffer (length: %d)', buffer.length) 314 | const res = this._extractor.write(buffer) 315 | if ( !res ) { 316 | debug('Extractor is full') 317 | this._full = true 318 | } 319 | done() 320 | } 321 | 322 | _processFeatures(features, audioBuffer) { 323 | this._frames.push(features) 324 | this._chunks.push(audioBuffer) 325 | const numFrames = this._frames.length 326 | // debug('Processing features (frames: %d, min: %d, max: %d', numFrames, this._minFrames, this._maxFrames) 327 | if ( numFrames >= this._minFrames ) { 328 | if ( this.buffering ) { 329 | this.buffering = false 330 | this.emit('ready') 331 | debug('Ready') 332 | } 333 | this._runDetection() 334 | } 335 | if ( numFrames >= this._maxFrames ) { 336 | this._frames.shift() 337 | this._chunks.shift() 338 | } 339 | } 340 | 341 | _runDetection() { 342 | const features = this._normalizeFeatures( this._frames ) 343 | const result = this._getBestKeyword(features) 344 | if ( result.keyword !== null ) { 345 | if ( result.keyword && result.keyword === this._state.keyword ) { 346 | if ( result.score < this._state.score ) { 347 | const timestamp = (new Date()).getTime() 348 | const audioData = Buffer.concat(this._chunks.slice(Math.round(-1.2 * result.frames))) 349 | const eventPayload = { 350 | ...result, 351 | score: this._state.score, 352 | audioData, 353 | timestamp 354 | } 355 | debugDetection('------------------------------------') 356 | debugDetection('Detected "%s" (%f)', eventPayload.keyword, eventPayload.score) 357 | debugDetection('------------------------------------') 358 | this.push(eventPayload) 359 | this.reset() 360 | return 361 | } 362 | } 363 | } 364 | debug('Detected keyword "%s" (%f)', result.keyword, result.score) 365 | this._state = result 366 | } 367 | 368 | _getBestKeyword(features) { 369 | let result = {keyword: null, score: 0, threshold: this.threshold} 370 | this._keywords.forEach(kw => { 371 | if ( !kw.enabled ) return 372 | const threshold = kw.threshold || this.threshold 373 | const templates = kw.templates 374 | templates.forEach((template, index) => { 375 | const frames = features.slice(Math.round(-1 * template.length)) 376 | const score = this._comparator.compare(template, frames) 377 | if ( score < threshold ) return 378 | if ( score < result.score ) return 379 | result = { 380 | ...result, 381 | keyword: kw.keyword, 382 | frames: template.length, 383 | threshold, 384 | score 385 | } 386 | }) 387 | }) 388 | return result 389 | } 390 | 391 | _normalizeFeatures(frames) { 392 | // Normalize by removing mean 393 | const numFrames = frames.length 394 | if ( numFrames === 0 ) return [] 395 | 396 | const numFeatures = frames[0].length 397 | const sum = new Array(numFeatures).fill(0) 398 | const normalizedFrames = new Array(numFrames) 399 | // Using for loop for speed 400 | // See benchmark: https://github.com/dg92/Performance-Analysis-JS 401 | for ( let i = 0 ; i < numFrames ; i++ ) { 402 | normalizedFrames[i] = new Array(numFeatures) 403 | for ( let j = 0; j < numFeatures ; j++ ) { 404 | sum[j] += frames[i][j] 405 | normalizedFrames[i][j] = frames[i][j] 406 | } 407 | } 408 | for ( let i = 0 ; i < numFrames ; i++ ) { 409 | for ( let j = 0; j < numFeatures ; j++ ) { 410 | normalizedFrames[i][j] = normalizedFrames[i][j] - sum[j] / numFrames 411 | } 412 | } 413 | return normalizedFrames 414 | } 415 | 416 | _createExtractor() { 417 | return new FeatureExtractor({ 418 | ...this.options, 419 | samplesPerFrame: this.samplesPerFrame, 420 | samplesPerShift: this.samplesPerShift, 421 | sampleRate: this.sampleRate 422 | }) 423 | } 424 | } 425 | 426 | WakewordDetector.VadMode = VoiceActivityFilter.Mode 427 | 428 | module.exports = WakewordDetector -------------------------------------------------------------------------------- /src/dtw/distance.js: -------------------------------------------------------------------------------- 1 | const euclideanDistance = function (x, y) { 2 | const difference = x - y 3 | const euclideanDistance = Math.sqrt(difference * difference) 4 | return euclideanDistance 5 | } 6 | 7 | module.exports = euclideanDistance -------------------------------------------------------------------------------- /src/dtw/dtw.js: -------------------------------------------------------------------------------- 1 | const Matrix = require('./matrix') 2 | const EuclidianDistance = require('./distance') 3 | 4 | class DTW { 5 | constructor(options) { 6 | options || (options = {}) 7 | this._state = {distanceCostMatrix: null} 8 | this._distanceFunction = options.distanceFunction || EuclidianDistance 9 | } 10 | 11 | compute(firstSequence, secondSequence, window) { 12 | let cost = Number.POSITIVE_INFINITY; 13 | if (typeof window === 'undefined') { 14 | cost = this._computeOptimalPath(firstSequence, secondSequence); 15 | } else if (typeof window === 'number') { 16 | cost = this._computeOptimalPathWithWindow(firstSequence, secondSequence, window); 17 | } else { 18 | throw new TypeError('Invalid window parameter type: expected a number'); 19 | } 20 | 21 | return cost; 22 | } 23 | 24 | _computeOptimalPath(s, t) { 25 | this._state.m = s.length; 26 | this._state.n = t.length; 27 | let distanceCostMatrix = Matrix.create(this._state.m, this._state.n, Number.POSITIVE_INFINITY); 28 | 29 | distanceCostMatrix[0][0] = this._distanceFunction(s[0], t[0]); 30 | 31 | for (let rowIndex = 1; rowIndex < this._state.m; rowIndex++) { 32 | var cost = this._distanceFunction(s[rowIndex], t[0]); 33 | distanceCostMatrix[rowIndex][0] = cost + distanceCostMatrix[rowIndex - 1][0]; 34 | } 35 | 36 | for (let columnIndex = 1; columnIndex < this._state.n; columnIndex++) { 37 | const cost = this._distanceFunction(s[0], t[columnIndex]); 38 | distanceCostMatrix[0][columnIndex] = cost + distanceCostMatrix[0][columnIndex - 1]; 39 | } 40 | 41 | for (let rowIndex = 1; rowIndex < this._state.m; rowIndex++) { 42 | for (let columnIndex = 1; columnIndex < this._state.n; columnIndex++) { 43 | const cost = this._distanceFunction(s[rowIndex], t[columnIndex]); 44 | distanceCostMatrix[rowIndex][columnIndex] = 45 | cost + Math.min( 46 | distanceCostMatrix[rowIndex - 1][columnIndex], // Insertion 47 | distanceCostMatrix[rowIndex][columnIndex - 1], // Deletion 48 | distanceCostMatrix[rowIndex - 1][columnIndex - 1]); // Match 49 | } 50 | } 51 | 52 | this._state.distanceCostMatrix = distanceCostMatrix; 53 | this._state.similarity = distanceCostMatrix[this._state.m - 1][this._state.n - 1]; 54 | return this._state.similarity; 55 | } 56 | 57 | _computeOptimalPathWithWindow(s, t, w) { 58 | this._state.m = s.length 59 | this._state.n = t.length 60 | const window = Math.max(w, Math.abs(s.length - t.length)) 61 | let distanceCostMatrix = Matrix.create(this._state.m + 1, this._state.n + 1, Number.POSITIVE_INFINITY) 62 | distanceCostMatrix[0][0] = 0 63 | 64 | for (let rowIndex = 1; rowIndex <= this._state.m; rowIndex++) { 65 | for (let columnIndex = Math.max(1, rowIndex - window); columnIndex <= Math.min(this._state.n, rowIndex + window); columnIndex++) { 66 | const cost = this._distanceFunction(s[rowIndex - 1], t[columnIndex - 1]) 67 | distanceCostMatrix[rowIndex][columnIndex] = 68 | cost + Math.min( 69 | distanceCostMatrix[rowIndex - 1][columnIndex], // Insertion 70 | distanceCostMatrix[rowIndex][columnIndex - 1], // Deletion 71 | distanceCostMatrix[rowIndex - 1][columnIndex - 1]) // Match 72 | } 73 | } 74 | 75 | distanceCostMatrix.shift() 76 | distanceCostMatrix = distanceCostMatrix.map(function (row) { 77 | return row.slice(1, row.length) 78 | }) 79 | this._state.distanceCostMatrix = distanceCostMatrix 80 | this._state.similarity = distanceCostMatrix[this._state.m - 1][this._state.n - 1] 81 | return this._state.similarity 82 | } 83 | 84 | path() { 85 | var path = null; 86 | if (this._state.distanceCostMatrix instanceof Array) { 87 | path = this._retrieveOptimalPath(); 88 | } 89 | 90 | return path; 91 | } 92 | 93 | _retrieveOptimalPath() { 94 | let rowIndex = this._state.m - 1 95 | let columnIndex = this._state.n - 1 96 | const path = [[rowIndex, columnIndex]] 97 | const epsilon = 1e-14 98 | while ((rowIndex > 0) || (columnIndex > 0)) { 99 | if ((rowIndex > 0) && (columnIndex > 0)) { 100 | const min = Math.min( 101 | this._state.distanceCostMatrix[rowIndex - 1][columnIndex], // Insertion 102 | this._state.distanceCostMatrix[rowIndex][columnIndex - 1], // Deletion 103 | this._state.distanceCostMatrix[rowIndex - 1][columnIndex - 1]) // Match 104 | if (min === this._state.distanceCostMatrix[rowIndex - 1][columnIndex - 1]) { 105 | rowIndex-- 106 | columnIndex-- 107 | } else if (min === this._state.distanceCostMatrix[rowIndex - 1][columnIndex]) { 108 | rowIndex-- 109 | } else if (min === this._state.distanceCostMatrix[rowIndex][columnIndex - 1]) { 110 | columnIndex-- 111 | } 112 | } else if ((rowIndex > 0) && (columnIndex === 0)) { 113 | rowIndex-- 114 | } else if ((rowIndex === 0) && (columnIndex > 0)) { 115 | columnIndex-- 116 | } 117 | 118 | path.push([rowIndex, columnIndex]) 119 | } 120 | 121 | return path.reverse() 122 | } 123 | } 124 | 125 | module.exports = DTW -------------------------------------------------------------------------------- /src/dtw/matrix.js: -------------------------------------------------------------------------------- 1 | const createArray = function (length, value) { 2 | if (typeof length !== 'number') { 3 | throw new TypeError('Invalid length type'); 4 | } 5 | 6 | if (typeof value === 'undefined') { 7 | throw new Error('Invalid value: expected a value to be provided'); 8 | } 9 | 10 | const array = new Array(length); 11 | for (let index = 0; index < length; index++) { 12 | array[index] = value; 13 | } 14 | 15 | return array; 16 | } 17 | 18 | const createMatrix = function (m, n, value) { 19 | const matrix = [] 20 | for (let rowIndex = 0; rowIndex < m; rowIndex++) { 21 | matrix.push(createArray(n, value)) 22 | } 23 | 24 | return matrix 25 | } 26 | 27 | module.exports = { 28 | create: createMatrix 29 | } 30 | -------------------------------------------------------------------------------- /src/extractor.js: -------------------------------------------------------------------------------- 1 | const Stream = require('stream') 2 | const Block = require('block-stream2') 3 | const debug = require('debug')('extractor') 4 | const Gist = require('@mathquis/node-gist') 5 | const Utils = require('./utils') 6 | 7 | class FeatureExtractor extends Stream.Transform { 8 | constructor(options) { 9 | super({ 10 | readableObjectMode: true 11 | }) 12 | this.options = options || {} 13 | this.samples = [] 14 | this._full = false 15 | this._extractor = new Gist(this.samplesPerFrame, this.sampleRate) 16 | this._block = new Block( this.samplesPerShift * this.sampleRate / 8000 ) 17 | 18 | this._block 19 | .on('drain', () => { 20 | debug('Block stream available') 21 | this._full = false 22 | }) 23 | .on('data', audioBuffer => { 24 | debug('Extracting from frame (length: %d)', audioBuffer.length) 25 | const newSamples = this.preEmphasis( audioBuffer ) 26 | if ( this.samples.length >= this.samplesPerFrame ) { 27 | this.samples = [...this.samples.slice(newSamples.length), ...newSamples] 28 | try { 29 | const features = this.extractFeatures( this.samples.slice(0, this.samplesPerFrame) ) 30 | debug('Features: %O', features) 31 | this.push({features, audioBuffer}) 32 | } catch (err) { 33 | this.error(err) 34 | } 35 | } else { 36 | this.samples = [...this.samples, ...newSamples] 37 | } 38 | }) 39 | .on('error', err => this.error(err)) 40 | } 41 | 42 | get full() { 43 | return this._full 44 | } 45 | 46 | get sampleRate() { 47 | return this.options.sampleRate || 16000 48 | } 49 | 50 | get samplesPerFrame() { 51 | return this.options.samplesPerFrame || 480 52 | } 53 | 54 | get samplesPerShift() { 55 | return this.options.samplesPerShift || 160 56 | } 57 | 58 | get preEmphasisCoefficient() { 59 | return this.options.preEmphasisCoefficient || 0.97 60 | } 61 | 62 | _write(audioData, enc, done) { 63 | if ( !this._block.write(audioData, enc, done) ) { 64 | debug('Block stream is full') 65 | this._full = true 66 | } 67 | } 68 | 69 | error(err) { 70 | this.emit('error', err) 71 | } 72 | 73 | destroy(err) { 74 | this._block.removeAllListeners() 75 | this._block.destroy() 76 | this._block = null 77 | 78 | this._extractor = null 79 | 80 | super.destroy(err) 81 | } 82 | 83 | preEmphasis(audioBuffer) { 84 | const coef = this.preEmphasisCoefficient 85 | const samples = Array 86 | .from( 87 | new Int16Array(audioBuffer.buffer, audioBuffer.byteOffset, audioBuffer.byteLength / Int16Array.BYTES_PER_ELEMENT) 88 | ) 89 | .map((v, i, list) => { 90 | return Utils.convertInt16ToFloat32( 91 | v - coef * ( list[i - 1] || 0 ) 92 | ) 93 | }) 94 | return samples 95 | } 96 | 97 | extractFeatures(audioFrame) { 98 | this._extractor.processAudioFrame(Float32Array.from(audioFrame)) 99 | return this._extractor.getMelFrequencyCepstralCoefficients().slice(1) 100 | } 101 | } 102 | 103 | module.exports = FeatureExtractor -------------------------------------------------------------------------------- /src/keyword.js: -------------------------------------------------------------------------------- 1 | const DTW = require('./dtw/dtw') 2 | const Comparator = require('./comparator') 3 | 4 | class WakewordKeyword { 5 | constructor(keyword, options) { 6 | this.keyword = keyword 7 | this.options = options || {} 8 | this._averagedTemplate = [] 9 | this._templates = [] 10 | this._enabled = true 11 | } 12 | 13 | get disableAveraging() { 14 | return this.options.disableAveraging || false 15 | } 16 | 17 | get threshold() { 18 | return this.options.threshold || 0 19 | } 20 | 21 | get templates() { 22 | return this.disableAveraging ? this._templates : [this._averagedTemplate] 23 | } 24 | 25 | get enabled() { 26 | return this._enabled 27 | } 28 | 29 | set enabled(state) { 30 | this._enabled = !!state 31 | } 32 | 33 | addFeatures(features) { 34 | this._templates = [...this._templates, features] 35 | if ( !this.disableAveraging ) this._averageTemplates() 36 | } 37 | 38 | _averageTemplates() { 39 | // According to Guoguo Chen (Kitt.ai) in this paper 40 | // http://citeseerx.ist.psu.edu/viewdoc/download?doi=10.1.1.684.8586&rep=rep1&type=pdf 41 | // Averaging the templates using DTW does not seem to impact accuracy 42 | // And greatly reduce resource consumption 43 | // We choose the longest template as the origin 44 | // And average each template with the previous average 45 | this._templates.sort((a, b) => a.length - b.length) 46 | 47 | let origin = this._templates[0] 48 | 49 | for ( let i = 1 ; i < this._templates.length ; i++ ) { 50 | const frames = this._templates[i] 51 | 52 | const dtw = new DTW({distanceFunction: Comparator.calculateDistance}) 53 | 54 | const score = dtw.compute(origin, frames) 55 | 56 | const avgs = origin.map(features => { 57 | return features.map(feature => { 58 | return [feature] 59 | }) 60 | }) 61 | 62 | dtw 63 | .path() 64 | .forEach(([x, y]) => { 65 | frames[y].forEach((feature, index) => { 66 | avgs[x][index].push(feature) 67 | }) 68 | }) 69 | 70 | origin = avgs.map(frame => { 71 | return frame.map(featureGroup => { 72 | return featureGroup.reduce((result, value) => result + value, 0) / featureGroup.length 73 | }) 74 | }) 75 | } 76 | 77 | this._averagedTemplate = origin 78 | } 79 | } 80 | 81 | module.exports = WakewordKeyword -------------------------------------------------------------------------------- /src/utils.js: -------------------------------------------------------------------------------- 1 | function convertInt16ToFloat32(n) { 2 | var v = n < 0 ? n / 32768 : n / 32767; // convert in range [-32768, 32767] 3 | return Math.max(-1, Math.min(1, v)); // clamp 4 | } 5 | 6 | function cosineSimilarity(vectorA = [], vectorB = []) { 7 | const dimensionality = Math.min(vectorA.length, vectorB.length); 8 | let dotAB = 0; 9 | let dotA = 0; 10 | let dotB = 0; 11 | let dimension = 0; 12 | while (dimension < dimensionality) { 13 | const componentA = vectorA[dimension]; 14 | const componentB = vectorB[dimension]; 15 | dotAB += componentA * componentB; 16 | dotA += componentA * componentA; 17 | dotB += componentB * componentB; 18 | dimension += 1; 19 | } 20 | 21 | const magnitude = Math.sqrt(dotA * dotB); 22 | return magnitude === 0 ? 0 : dotAB / magnitude; 23 | } 24 | 25 | module.exports = { 26 | convertInt16ToFloat32, 27 | cosineSimilarity 28 | } -------------------------------------------------------------------------------- /src/vad.js: -------------------------------------------------------------------------------- 1 | const VAD = require('node-vad') 2 | 3 | class VoiceActivityFilter { 4 | constructor(options) { 5 | this.options = options || {} 6 | this._debouncing = this.debounce 7 | this._vad = new VAD(this.vadMode) 8 | } 9 | 10 | get sampleRate() { 11 | return this.options.sampleRate || 16000 12 | } 13 | 14 | get vadMode() { 15 | return this.options.vadMode || VAD.Mode.VERY_AGGRESSIVE 16 | } 17 | 18 | get vadDebounceTime() { 19 | return this.options.vadDebounceTime || 1000 20 | } 21 | 22 | get debounce() { 23 | return this.options.debounce || 20 24 | } 25 | 26 | async processAudio(audioBuffer) { 27 | if ( this._debouncing > 0 ) { 28 | this._debouncing-- 29 | return true 30 | } 31 | const res = await this._vad.processAudio(audioBuffer, this.sampleRate) 32 | if ( res === VAD.Event.VOICE ) { 33 | this._debouncing = this.debounce 34 | return true 35 | } 36 | return false 37 | } 38 | 39 | destroy(err) { 40 | this._vad = null 41 | } 42 | } 43 | 44 | VoiceActivityFilter.Mode = VAD.Mode 45 | 46 | module.exports = VoiceActivityFilter --------------------------------------------------------------------------------