@mieweb/ui 0.6.1-dev.165 → 0.6.1-dev.166
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/dist/brands/index.cjs +7 -7
- package/dist/brands/index.js +2 -2
- package/dist/{chunk-NBD236EH.js → chunk-5O23GUS3.js} +15 -2
- package/dist/chunk-5O23GUS3.js.map +1 -0
- package/dist/chunk-6I7IDZ4A.js +51 -0
- package/dist/chunk-6I7IDZ4A.js.map +1 -0
- package/dist/{chunk-Z6NRP4Z5.cjs → chunk-JWTCEWQ4.cjs} +2 -2
- package/dist/{chunk-Z6NRP4Z5.cjs.map → chunk-JWTCEWQ4.cjs.map} +1 -1
- package/dist/{chunk-Y65SK5Y2.cjs → chunk-MJ7YITLN.cjs} +2 -2
- package/dist/{chunk-Y65SK5Y2.cjs.map → chunk-MJ7YITLN.cjs.map} +1 -1
- package/dist/{chunk-R6PBBPU3.js → chunk-TXRQQMG5.js} +2 -2
- package/dist/{chunk-R6PBBPU3.js.map → chunk-TXRQQMG5.js.map} +1 -1
- package/dist/chunk-UVSODK6V.cjs +53 -0
- package/dist/chunk-UVSODK6V.cjs.map +1 -0
- package/dist/{chunk-YYDW3ZZS.cjs → chunk-W5B3VQUQ.cjs} +15 -2
- package/dist/chunk-W5B3VQUQ.cjs.map +1 -0
- package/dist/{chunk-NSLR3B7K.js → chunk-XVF472GT.js} +2 -2
- package/dist/{chunk-NSLR3B7K.js.map → chunk-XVF472GT.js.map} +1 -1
- package/dist/components/Markdown/index.cjs +10 -10
- package/dist/components/Markdown/index.js +2 -2
- package/dist/components/Skeleton/index.d.cts +1 -1
- package/dist/components/Skeleton/index.d.ts +1 -1
- package/dist/hey-buddy-CLUVAY2X.cjs +1133 -0
- package/dist/hey-buddy-CLUVAY2X.cjs.map +1 -0
- package/dist/hey-buddy-NMSWZ4TN.js +1131 -0
- package/dist/hey-buddy-NMSWZ4TN.js.map +1 -0
- package/dist/index.cjs +4345 -542
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +786 -1
- package/dist/index.d.ts +786 -1
- package/dist/index.js +4321 -569
- package/dist/index.js.map +1 -1
- package/dist/speaker-verify-5GTWAN5Y.cjs +357 -0
- package/dist/speaker-verify-5GTWAN5Y.cjs.map +1 -0
- package/dist/speaker-verify-R67P433H.js +355 -0
- package/dist/speaker-verify-R67P433H.js.map +1 -0
- package/dist/styles/init.css +5 -0
- package/dist/styles.css +1 -1
- package/dist/tailwind-preset.cjs +4 -4
- package/dist/tailwind-preset.js +1 -1
- package/package.json +2 -1
- package/dist/chunk-NBD236EH.js.map +0 -1
- package/dist/chunk-YYDW3ZZS.cjs.map +0 -1
|
@@ -0,0 +1,1131 @@
|
|
|
1
|
+
import { getModelBytes } from './chunk-6I7IDZ4A.js';
|
|
2
|
+
|
|
3
|
+
// src/components/AI/HeyOzwell/WakeWord/lib/helpers.js
|
|
4
|
+
var sleep = (ms) => {
|
|
5
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
6
|
+
};
|
|
7
|
+
|
|
8
|
+
// src/components/AI/HeyOzwell/WakeWord/lib/onnx.js
|
|
9
|
+
var initialized = false;
|
|
10
|
+
var initError = null;
|
|
11
|
+
var Tensor;
|
|
12
|
+
var InferenceSession;
|
|
13
|
+
if (typeof ort !== "undefined") {
|
|
14
|
+
initialized = true;
|
|
15
|
+
Tensor = ort.Tensor;
|
|
16
|
+
InferenceSession = ort.InferenceSession;
|
|
17
|
+
} else {
|
|
18
|
+
import('onnxruntime-web').then((module) => {
|
|
19
|
+
try {
|
|
20
|
+
if (!module.env.wasm.wasmPaths) {
|
|
21
|
+
const v = module.env && module.env.versions && module.env.versions.web || "";
|
|
22
|
+
module.env.wasm.wasmPaths = "https://cdn.jsdelivr.net/npm/onnxruntime-web" + (v ? "@" + v : "") + "/dist/";
|
|
23
|
+
}
|
|
24
|
+
module.env.wasm.numThreads = 1;
|
|
25
|
+
} catch (e) {
|
|
26
|
+
}
|
|
27
|
+
initialized = true;
|
|
28
|
+
Tensor = module.Tensor;
|
|
29
|
+
InferenceSession = module.InferenceSession;
|
|
30
|
+
}).catch((e) => {
|
|
31
|
+
initError = e instanceof Error ? e : new Error(String(e));
|
|
32
|
+
});
|
|
33
|
+
}
|
|
34
|
+
var ONNX = class _ONNX {
|
|
35
|
+
/**
|
|
36
|
+
* Wait for the ONNX Runtime Web API to be initialized.
|
|
37
|
+
* @returns {Promise<void>} A promise that resolves when the ONNX Runtime Web API is initialized.
|
|
38
|
+
*/
|
|
39
|
+
static async waitForInitialization() {
|
|
40
|
+
while (!initialized) {
|
|
41
|
+
if (initError) throw new Error("ONNX Runtime Web failed to load: " + initError.message);
|
|
42
|
+
await sleep(10);
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
/**
|
|
46
|
+
* Create a new tensor.
|
|
47
|
+
* @param {string} dtype The data type of the tensor.
|
|
48
|
+
* @param {Array<number>} data The data of the tensor.
|
|
49
|
+
* @param {Array<number>} dims The dimensions of the tensor.
|
|
50
|
+
* @returns {Promise<Tensor>} A promise that resolves to a new tensor.
|
|
51
|
+
*/
|
|
52
|
+
static async createTensor(dtype, data, dims) {
|
|
53
|
+
await _ONNX.waitForInitialization();
|
|
54
|
+
return new Tensor(dtype, data, dims);
|
|
55
|
+
}
|
|
56
|
+
/**
|
|
57
|
+
* Create a new inference session.
|
|
58
|
+
* @param {ArrayBuffer} model The model to load.
|
|
59
|
+
* @param {Object} [options] The options for the inference session.
|
|
60
|
+
* @returns {Promise<InferenceSession>} A promise that resolves to a new inference session.
|
|
61
|
+
*/
|
|
62
|
+
static async createInferenceSession(model, options = {}) {
|
|
63
|
+
await _ONNX.waitForInitialization();
|
|
64
|
+
return await InferenceSession.create(model, options);
|
|
65
|
+
}
|
|
66
|
+
};
|
|
67
|
+
ONNX.waitForInitialization().then(() => {
|
|
68
|
+
ONNX.createTensor = (dtype, data, dims) => new Tensor(dtype, data, dims);
|
|
69
|
+
ONNX.createInferenceSession = (model, options = {}) => InferenceSession.create(model, options);
|
|
70
|
+
}).catch(() => {
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
// src/components/AI/HeyOzwell/WakeWord/lib/audio.js
|
|
74
|
+
var workletName = "hey-buddy";
|
|
75
|
+
var workletBlob = new Blob([`(()=>{class t extends AudioWorkletProcessor{constructor(t){super(t),this.targetSampleRate=t.processorOptions.targetSampleRate,this.inputBuffer=new Float32Array(this.inputFrameSize),this.inputBufferSize=0,this.outputBuffer=new Float32Array(this.targetFrameSize)}get inputFrameSize(){return Math.round(sampleRate/50)}get targetFrameSize(){return Math.round(this.targetSampleRate/50)}async flush(){const t=sampleRate/this.targetSampleRate;this.outputBuffer.fill(0);for(let e=0;e<this.targetFrameSize;e++){const i=e*t,r=Math.floor(i),s=Math.min(r+1,this.inputFrameSize-1),u=i-r;this.outputBuffer[e]=this.inputBuffer[r]*(1-u)+this.inputBuffer[s]*u}await this.port.postMessage(this.outputBuffer)}pushAudio(t){const e=t.length,i=this.inputFrameSize-this.inputBufferSize;if(e<i)return this.inputBuffer.set(t,this.inputBufferSize),void(this.inputBufferSize+=e);this.inputBuffer.set(t.subarray(0,i),this.inputBufferSize),this.flush(),this.inputBufferSize=0,this.pushAudio(t.subarray(i))}process(t,e,i){return this.pushAudio(t[0][0]),!0}}registerProcessor("${workletName}",t)})();`], { type: "application/javascript" });
|
|
76
|
+
var workletUrl = URL.createObjectURL(workletBlob);
|
|
77
|
+
var AudioBatcher = class {
|
|
78
|
+
/**
|
|
79
|
+
* @param {number} batchSeconds - The number of seconds to batch.
|
|
80
|
+
* @param {number} batchIntervalSeconds - The number of seconds to wait before calling the callback.
|
|
81
|
+
* @param {number} targetSampleRate - The target sample rate of the worklet.
|
|
82
|
+
*/
|
|
83
|
+
constructor(batchSeconds = 2, batchIntervalSeconds = 0.05, targetSampleRate = 16e3) {
|
|
84
|
+
this.initialized = false;
|
|
85
|
+
this.initError = null;
|
|
86
|
+
this.callbacks = [];
|
|
87
|
+
this.batchSeconds = batchSeconds;
|
|
88
|
+
this.batchIntervalSeconds = batchIntervalSeconds;
|
|
89
|
+
this.batchIntervalCount = 0;
|
|
90
|
+
this.targetSampleRate = targetSampleRate;
|
|
91
|
+
this.buffer = new Float32Array(this.batchSamples);
|
|
92
|
+
this.buffer.fill(0);
|
|
93
|
+
this.initialize().catch((e) => {
|
|
94
|
+
this.initError = e;
|
|
95
|
+
console.error("[AudioBatcher] initialization failed", e);
|
|
96
|
+
});
|
|
97
|
+
}
|
|
98
|
+
/**
|
|
99
|
+
* The number of samples in a batch.
|
|
100
|
+
* @type {number}
|
|
101
|
+
*/
|
|
102
|
+
get batchSamples() {
|
|
103
|
+
return Math.floor(this.batchSeconds * this.targetSampleRate);
|
|
104
|
+
}
|
|
105
|
+
/**
|
|
106
|
+
* The number of samples in a batch interval.
|
|
107
|
+
* @type {number}
|
|
108
|
+
*/
|
|
109
|
+
get batchIntervalSamples() {
|
|
110
|
+
return Math.floor(this.batchIntervalSeconds * this.targetSampleRate);
|
|
111
|
+
}
|
|
112
|
+
/**
|
|
113
|
+
* Clears the buffer.
|
|
114
|
+
*/
|
|
115
|
+
clearBuffer() {
|
|
116
|
+
this.buffer.fill(0);
|
|
117
|
+
}
|
|
118
|
+
/**
|
|
119
|
+
* Pushes new audio samples into the buffer.
|
|
120
|
+
* @param {Float32Array} data - The new audio samples.
|
|
121
|
+
*/
|
|
122
|
+
push(data) {
|
|
123
|
+
const dataLength = data.length;
|
|
124
|
+
if (dataLength >= this.buffer.length) {
|
|
125
|
+
this.buffer.set(data.subarray(dataLength - this.buffer.length));
|
|
126
|
+
} else {
|
|
127
|
+
this.buffer.set(this.buffer.subarray(dataLength));
|
|
128
|
+
this.buffer.set(data, this.buffer.length - dataLength);
|
|
129
|
+
}
|
|
130
|
+
this.batchIntervalCount += dataLength;
|
|
131
|
+
if (this.batchIntervalCount >= this.batchIntervalSamples) {
|
|
132
|
+
this.callbacks.forEach((callback) => callback(this.buffer));
|
|
133
|
+
this.batchIntervalCount = 0;
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
/**
|
|
137
|
+
* Adds a callback to be called with each batch.
|
|
138
|
+
* @param {Function} callback - The callback to add.
|
|
139
|
+
*/
|
|
140
|
+
onBatch(callback) {
|
|
141
|
+
this.callbacks.push(callback);
|
|
142
|
+
}
|
|
143
|
+
/**
|
|
144
|
+
* Removes a callback from the list of callbacks.
|
|
145
|
+
* @param {Function} callback - The callback to remove.
|
|
146
|
+
*/
|
|
147
|
+
offBatch(callback) {
|
|
148
|
+
this.callbacks = this.callbacks.filter((c) => c !== callback);
|
|
149
|
+
}
|
|
150
|
+
/**
|
|
151
|
+
* Initializes the audio batcher.
|
|
152
|
+
*/
|
|
153
|
+
async initialize() {
|
|
154
|
+
if (this.initialized) {
|
|
155
|
+
return;
|
|
156
|
+
}
|
|
157
|
+
this.stream = await navigator.mediaDevices.getUserMedia({
|
|
158
|
+
audio: {
|
|
159
|
+
channelCount: 1,
|
|
160
|
+
echoCancellation: true,
|
|
161
|
+
autoGainControl: true,
|
|
162
|
+
noiseSuppression: true
|
|
163
|
+
}
|
|
164
|
+
});
|
|
165
|
+
this.audioContext = new AudioContext();
|
|
166
|
+
this.audioContext.resume().catch(() => {
|
|
167
|
+
});
|
|
168
|
+
this.sourceNode = new MediaStreamAudioSourceNode(
|
|
169
|
+
this.audioContext,
|
|
170
|
+
{ mediaStream: this.stream }
|
|
171
|
+
);
|
|
172
|
+
this.workerNode = await AudioNode.create(
|
|
173
|
+
this.audioContext,
|
|
174
|
+
this.targetSampleRate
|
|
175
|
+
);
|
|
176
|
+
this.sourceNode.connect(this.workerNode.worker);
|
|
177
|
+
this.workerNode.worker.port.onmessage = (event) => {
|
|
178
|
+
this.push(event.data);
|
|
179
|
+
};
|
|
180
|
+
this.clearBuffer();
|
|
181
|
+
this.initialized = true;
|
|
182
|
+
}
|
|
183
|
+
};
|
|
184
|
+
var AudioNode = class _AudioNode {
|
|
185
|
+
/**
|
|
186
|
+
* @param {AudioContext} context - The audio context.
|
|
187
|
+
* @param {AudioWorkletNode} worker - The audio worklet node.
|
|
188
|
+
*/
|
|
189
|
+
constructor(context, worker) {
|
|
190
|
+
this.context = context;
|
|
191
|
+
this.worker = worker;
|
|
192
|
+
}
|
|
193
|
+
/**
|
|
194
|
+
* Creates an AudioNode.
|
|
195
|
+
* @param {AudioContext} context - The audio context.
|
|
196
|
+
* @param {number} targetSampleRate - The target sample rate of the worklet.
|
|
197
|
+
* @returns {Promise<AudioNode>} The created AudioNode.
|
|
198
|
+
*/
|
|
199
|
+
static async create(context, targetSampleRate) {
|
|
200
|
+
await context.audioWorklet.addModule(workletUrl);
|
|
201
|
+
const workletOptions = {
|
|
202
|
+
processorOptions: {
|
|
203
|
+
targetSampleRate
|
|
204
|
+
}
|
|
205
|
+
};
|
|
206
|
+
const worker = new AudioWorkletNode(context, workletName, workletOptions);
|
|
207
|
+
return new _AudioNode(context, worker);
|
|
208
|
+
}
|
|
209
|
+
};
|
|
210
|
+
|
|
211
|
+
// src/components/AI/HeyOzwell/WakeWord/lib/models/base.js
|
|
212
|
+
var ONNXModel = class {
|
|
213
|
+
/**
|
|
214
|
+
* Constructor
|
|
215
|
+
* @param {string} modelPath - Path to the ONNX model
|
|
216
|
+
* @param {Object} options - Options
|
|
217
|
+
*/
|
|
218
|
+
constructor(modelPath, power = 0, webnn = 1, webgpu = 2, webgl = 3, wasm = 4) {
|
|
219
|
+
this.modelPath = modelPath;
|
|
220
|
+
this.session = null;
|
|
221
|
+
this.loadError = null;
|
|
222
|
+
this.duration = 0;
|
|
223
|
+
this.ema = 0.1;
|
|
224
|
+
this.lastTime = 0;
|
|
225
|
+
this.webnn = webnn;
|
|
226
|
+
this.webgpu = webgpu;
|
|
227
|
+
this.webgl = webgl;
|
|
228
|
+
this.wasm = wasm;
|
|
229
|
+
this.power = power;
|
|
230
|
+
this.loadPromise = this.load().catch((e) => {
|
|
231
|
+
this.loadError = e;
|
|
232
|
+
console.warn("[ONNXModel] load failed:", e);
|
|
233
|
+
});
|
|
234
|
+
}
|
|
235
|
+
/**
|
|
236
|
+
* Get the power preference
|
|
237
|
+
* @returns {string} - Power preference
|
|
238
|
+
*/
|
|
239
|
+
get powerPreference() {
|
|
240
|
+
switch (this.power) {
|
|
241
|
+
case -1:
|
|
242
|
+
return "low-power";
|
|
243
|
+
case 1:
|
|
244
|
+
return "high-performance";
|
|
245
|
+
default:
|
|
246
|
+
return "default";
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
/**
|
|
250
|
+
* Get the execution providers
|
|
251
|
+
* @returns {Array} - Execution providers
|
|
252
|
+
*/
|
|
253
|
+
get executionProviders() {
|
|
254
|
+
const providerIndexes = [];
|
|
255
|
+
if (Number.isInteger(this.webnn)) {
|
|
256
|
+
providerIndexes.push([{
|
|
257
|
+
name: "webnn",
|
|
258
|
+
device: "gpu",
|
|
259
|
+
powerPreference: this.powerPreference
|
|
260
|
+
}, this.webnn]);
|
|
261
|
+
}
|
|
262
|
+
if (Number.isInteger(this.webgpu)) {
|
|
263
|
+
providerIndexes.push(["webgpu", this.webgpu]);
|
|
264
|
+
}
|
|
265
|
+
if (Number.isInteger(this.webgl)) {
|
|
266
|
+
providerIndexes.push(["webgl", this.webgl]);
|
|
267
|
+
}
|
|
268
|
+
if (Number.isInteger(this.wasm)) {
|
|
269
|
+
providerIndexes.push(["wasm", this.wasm]);
|
|
270
|
+
}
|
|
271
|
+
providerIndexes.sort((a, b) => a[1] - b[1]);
|
|
272
|
+
return providerIndexes.map((providerIndex) => providerIndex[0]);
|
|
273
|
+
}
|
|
274
|
+
/**
|
|
275
|
+
* Get the session options
|
|
276
|
+
* @returns {Object} - Session options
|
|
277
|
+
* @see https://onnxruntime.ai/docs/tutorials/web/env-flags-and-session-options.html#session-options
|
|
278
|
+
*/
|
|
279
|
+
get sessionOptions() {
|
|
280
|
+
return {
|
|
281
|
+
executionProviders: ["wasm"]
|
|
282
|
+
};
|
|
283
|
+
}
|
|
284
|
+
/**
|
|
285
|
+
* Initialize the model
|
|
286
|
+
*/
|
|
287
|
+
async load() {
|
|
288
|
+
const bytes = await getModelBytes(this.modelPath);
|
|
289
|
+
this.session = await ONNX.createInferenceSession(bytes, this.sessionOptions);
|
|
290
|
+
}
|
|
291
|
+
/**
|
|
292
|
+
* Waits until the model is loaded
|
|
293
|
+
*/
|
|
294
|
+
async waitUntilLoaded() {
|
|
295
|
+
await this.loadPromise;
|
|
296
|
+
if (this.loadError) throw this.loadError;
|
|
297
|
+
}
|
|
298
|
+
/**
|
|
299
|
+
* Execute the model
|
|
300
|
+
* @param {Mixed} input - Input data
|
|
301
|
+
* @returns {Promise} - Promise that resolves with the output of the model
|
|
302
|
+
* @throws {Error} - If the method is not implemented
|
|
303
|
+
*/
|
|
304
|
+
async execute(input) {
|
|
305
|
+
throw new Error("Not Implemented");
|
|
306
|
+
}
|
|
307
|
+
/**
|
|
308
|
+
* Run the model
|
|
309
|
+
* @param {Mixed} input - Input data
|
|
310
|
+
* @returns {Promise} - Promise that resolves with the output of the model
|
|
311
|
+
*/
|
|
312
|
+
async run(input) {
|
|
313
|
+
await this.waitUntilLoaded();
|
|
314
|
+
const currentTime = (/* @__PURE__ */ new Date()).getTime();
|
|
315
|
+
const result = await this.execute(input);
|
|
316
|
+
const executionDuration = (/* @__PURE__ */ new Date()).getTime() - currentTime;
|
|
317
|
+
if (this.duration === 0) {
|
|
318
|
+
this.duration = executionDuration;
|
|
319
|
+
} else {
|
|
320
|
+
this.duration = (1 - this.ema) * this.duration + this.ema * executionDuration;
|
|
321
|
+
}
|
|
322
|
+
this.lastTime = currentTime;
|
|
323
|
+
return result;
|
|
324
|
+
}
|
|
325
|
+
};
|
|
326
|
+
|
|
327
|
+
// src/components/AI/HeyOzwell/WakeWord/lib/models/vad.js
|
|
328
|
+
var SileroVAD = class extends ONNXModel {
|
|
329
|
+
/**
|
|
330
|
+
* Constructor
|
|
331
|
+
* @param {string} modelPath - Path to the ONNX model
|
|
332
|
+
* @param {number} sampleRate - Sample rate of the input audio
|
|
333
|
+
* @param {number} speechVadThreshold - Threshold for speech detection (default: 0.65)
|
|
334
|
+
* @param {number} silenceVadThreshold - Threshold for silence detection (default: 0.4)
|
|
335
|
+
* @param {number} silentFramesCount - Number of silent frames to consider speech ended (default: 10)
|
|
336
|
+
*/
|
|
337
|
+
constructor(modelPath = "/pretrained/silero-vad.onnx", sampleRate = 16e3, speechVadThreshold = 0.65, silenceVadThreshold = 0.4, silentFramesCount = 10, power = 0, webnn = 1, webgpu = 2, webgl = 3, wasm = 4) {
|
|
338
|
+
super(
|
|
339
|
+
modelPath,
|
|
340
|
+
power,
|
|
341
|
+
webnn,
|
|
342
|
+
webgpu,
|
|
343
|
+
webgl,
|
|
344
|
+
wasm
|
|
345
|
+
);
|
|
346
|
+
this.sampleRate = sampleRate || 16e3;
|
|
347
|
+
this.speechVadThreshold = speechVadThreshold;
|
|
348
|
+
this.silenceVadThreshold = silenceVadThreshold;
|
|
349
|
+
this.silentFramesCount = silentFramesCount;
|
|
350
|
+
this.silentFrames = 0;
|
|
351
|
+
this.isSpeaking = false;
|
|
352
|
+
}
|
|
353
|
+
/**
|
|
354
|
+
* Test the model
|
|
355
|
+
* @param {boolean} debug - If true, log the result to the console
|
|
356
|
+
* @throws {Error} - If the model fails the test
|
|
357
|
+
*/
|
|
358
|
+
async test(debug = false) {
|
|
359
|
+
let result = await this.run(new Float32Array(16e3).fill(0));
|
|
360
|
+
if (!isNaN(result) && 0 <= result && result <= 1) {
|
|
361
|
+
if (debug) {
|
|
362
|
+
console.log(`VAD model OK, executed in ${this.duration} ms`);
|
|
363
|
+
}
|
|
364
|
+
} else {
|
|
365
|
+
throw new Error(`VAD model failed - got ${result}`);
|
|
366
|
+
}
|
|
367
|
+
}
|
|
368
|
+
/**
|
|
369
|
+
* Execute the model
|
|
370
|
+
* @param {Float32Array} input - Input data
|
|
371
|
+
* @returns {Promise} - Promise that resolves with the output of the model, which is a single float
|
|
372
|
+
* @throws {Error} - If the input data is not a Float32Array
|
|
373
|
+
*/
|
|
374
|
+
async execute(input) {
|
|
375
|
+
if (this.h === void 0 || this.c === void 0 || this.sr === void 0) {
|
|
376
|
+
this.sr = await ONNX.createTensor("int64", [this.sampleRate], [1]);
|
|
377
|
+
this.h = await ONNX.createTensor("float32", new Array(128).fill(0), [2, 1, 64]);
|
|
378
|
+
this.c = await ONNX.createTensor("float32", new Array(128).fill(0), [2, 1, 64]);
|
|
379
|
+
}
|
|
380
|
+
const inputTensor = await ONNX.createTensor("float32", input, [1, input.length]);
|
|
381
|
+
const output = await this.session.run({
|
|
382
|
+
input: inputTensor,
|
|
383
|
+
h: this.h,
|
|
384
|
+
c: this.c,
|
|
385
|
+
sr: this.sr
|
|
386
|
+
});
|
|
387
|
+
this.c = output.cn;
|
|
388
|
+
this.h = output.hn;
|
|
389
|
+
return output.output.data[0];
|
|
390
|
+
}
|
|
391
|
+
/**
|
|
392
|
+
* Determines if speech is present in the audio, with debouncing logic so a brief pause between
|
|
393
|
+
* words doesn't split one utterance into two: if speech is followed by a short silence and then
|
|
394
|
+
* more speech, the whole span (including the gap) is still treated as speech.
|
|
395
|
+
*
|
|
396
|
+
* @param {Float32Array} audio - Audio data to check for speech
|
|
397
|
+
* @returns {Promise<Object>} - Promise that resolves with an object containing:
|
|
398
|
+
* - isSpeaking: boolean - true if speech is detected, false otherwise
|
|
399
|
+
* - probability: number - the raw VAD probability score (0-1)
|
|
400
|
+
*/
|
|
401
|
+
async hasSpeechAudio(audio) {
|
|
402
|
+
const speechProbability = await this.run(audio);
|
|
403
|
+
const hasSpeech = speechProbability > this.speechVadThreshold;
|
|
404
|
+
const hasSilence = speechProbability < this.silenceVadThreshold;
|
|
405
|
+
let justStoppedSpeaking = false;
|
|
406
|
+
let justStartedSpeaking = false;
|
|
407
|
+
if (!hasSpeech) {
|
|
408
|
+
if (hasSilence) {
|
|
409
|
+
this.silentFrames += 1;
|
|
410
|
+
if (this.isSpeaking && this.silentFrames > this.silentFramesCount) {
|
|
411
|
+
this.isSpeaking = false;
|
|
412
|
+
justStoppedSpeaking = true;
|
|
413
|
+
}
|
|
414
|
+
}
|
|
415
|
+
} else {
|
|
416
|
+
this.silentFrames = 0;
|
|
417
|
+
if (!this.isSpeaking) {
|
|
418
|
+
this.isSpeaking = true;
|
|
419
|
+
justStartedSpeaking = true;
|
|
420
|
+
}
|
|
421
|
+
}
|
|
422
|
+
return {
|
|
423
|
+
isSpeaking: this.isSpeaking,
|
|
424
|
+
speechProbability,
|
|
425
|
+
justStoppedSpeaking,
|
|
426
|
+
justStartedSpeaking
|
|
427
|
+
};
|
|
428
|
+
}
|
|
429
|
+
};
|
|
430
|
+
|
|
431
|
+
// src/components/AI/HeyOzwell/WakeWord/lib/models/mel-spectrogram.js
|
|
432
|
+
var MelSpectrogram = class extends ONNXModel {
|
|
433
|
+
/**
|
|
434
|
+
* Constructor
|
|
435
|
+
* @param {string} modelPath - Path to the ONNX model
|
|
436
|
+
*/
|
|
437
|
+
constructor(modelPath = "/pretrained/mel-spectrogram.onnx", power = 0, webnn = 1, webgpu = 2, webgl = 3, wasm = 4) {
|
|
438
|
+
super(
|
|
439
|
+
modelPath,
|
|
440
|
+
power,
|
|
441
|
+
webnn,
|
|
442
|
+
webgpu,
|
|
443
|
+
webgl,
|
|
444
|
+
wasm
|
|
445
|
+
);
|
|
446
|
+
}
|
|
447
|
+
/**
|
|
448
|
+
* Test the model
|
|
449
|
+
* @param {boolean} debug - If true, print debug information
|
|
450
|
+
* @throws {Error} - If the model fails the test
|
|
451
|
+
*/
|
|
452
|
+
async test(debug = false) {
|
|
453
|
+
let result = await this.run(new Float32Array(12640).fill(1));
|
|
454
|
+
if (result.dims.length === 4 && result.dims[2] === 76 && result.dims[3] === 32) {
|
|
455
|
+
if (debug) {
|
|
456
|
+
console.log(`Mel spectrogram model OK, executed in ${this.duration} ms`);
|
|
457
|
+
}
|
|
458
|
+
} else {
|
|
459
|
+
throw new Error("Mel spectrogram model failed");
|
|
460
|
+
}
|
|
461
|
+
}
|
|
462
|
+
/**
|
|
463
|
+
* Execute the model
|
|
464
|
+
* @param {Float32Array} input - Input data
|
|
465
|
+
* @returns {Promise} - Promise that resolves with the output of the model, which is a 2D array
|
|
466
|
+
* @throws {Error} - If the input data is not a Float32Array
|
|
467
|
+
*/
|
|
468
|
+
async execute(input) {
|
|
469
|
+
let peak = 0;
|
|
470
|
+
for (let i = 0; i < input.length; i++) {
|
|
471
|
+
const a = Math.abs(input[i]);
|
|
472
|
+
if (a > peak) peak = a;
|
|
473
|
+
}
|
|
474
|
+
if (peak > 1e-5) {
|
|
475
|
+
const normed = new Float32Array(input.length);
|
|
476
|
+
for (let i = 0; i < input.length; i++) normed[i] = input[i] / peak;
|
|
477
|
+
input = normed;
|
|
478
|
+
}
|
|
479
|
+
const inputTensor = await ONNX.createTensor(
|
|
480
|
+
"float32",
|
|
481
|
+
input,
|
|
482
|
+
[1, input.length]
|
|
483
|
+
);
|
|
484
|
+
const output = await this.session.run({ input: inputTensor });
|
|
485
|
+
const data = output.output.data;
|
|
486
|
+
for (let i = 0; i < data.length; i++) data[i] = data[i] / 10 + 2;
|
|
487
|
+
return await ONNX.createTensor("float32", data, output.output.dims);
|
|
488
|
+
}
|
|
489
|
+
};
|
|
490
|
+
|
|
491
|
+
// src/components/AI/HeyOzwell/WakeWord/lib/models/speech-embedding.js
|
|
492
|
+
var SpeechEmbedding = class extends ONNXModel {
|
|
493
|
+
/**
|
|
494
|
+
* Constructor
|
|
495
|
+
* @param {string} modelPath - Path to the ONNX model
|
|
496
|
+
* @param {MelSpectrogram} spectrogramModel - Mel spectrogram model
|
|
497
|
+
* @param {number} spectrogramMelBins - Number of Mel bins for the Mel spectrogram model
|
|
498
|
+
* @param {number} embeddingDim - Dimension of the embeddings
|
|
499
|
+
* @param {number} windowSize - Size of the window
|
|
500
|
+
* @param {number} windowStride - Stride of the window
|
|
501
|
+
*/
|
|
502
|
+
constructor(modelPath, embeddingDim = 96, windowSize = 76, windowStride = 8, power = 0, webnn = 1, webgpu = 2, webgl = 3, wasm = 4) {
|
|
503
|
+
super(
|
|
504
|
+
modelPath,
|
|
505
|
+
power,
|
|
506
|
+
webnn,
|
|
507
|
+
webgpu,
|
|
508
|
+
webgl,
|
|
509
|
+
wasm
|
|
510
|
+
);
|
|
511
|
+
this.embeddingDim = embeddingDim;
|
|
512
|
+
this.windowSize = windowSize;
|
|
513
|
+
this.windowStride = windowStride;
|
|
514
|
+
}
|
|
515
|
+
/**
|
|
516
|
+
* Test the model
|
|
517
|
+
* @param {boolean} debug - Debug mode
|
|
518
|
+
* @throws {Error} - If the model fails the test
|
|
519
|
+
*/
|
|
520
|
+
async test(debug = false) {
|
|
521
|
+
const melTensor = await ONNX.createTensor(
|
|
522
|
+
"float32",
|
|
523
|
+
new Float32Array(100 * 32),
|
|
524
|
+
// already zero-initialized
|
|
525
|
+
[100, 32]
|
|
526
|
+
);
|
|
527
|
+
let result = await this.run(melTensor);
|
|
528
|
+
if (result.dims.length === 2 && result.dims[0] === 4 && result.dims[1] === 96) {
|
|
529
|
+
if (debug) {
|
|
530
|
+
console.log(`Speech embedding model OK, executed in ${this.duration} ms`);
|
|
531
|
+
}
|
|
532
|
+
} else {
|
|
533
|
+
console.error("Unexpected speech embedding result", result);
|
|
534
|
+
throw new Error("Speech embedding model failed");
|
|
535
|
+
}
|
|
536
|
+
}
|
|
537
|
+
/**
|
|
538
|
+
* Extracts speech embeddings from a mel spectrogram output
|
|
539
|
+
*
|
|
540
|
+
* This function takes the output from a mel spectrogram model, creates an ONNX tensor
|
|
541
|
+
* with the appropriate dimensions, and runs it through the speech embedding model to
|
|
542
|
+
* generate embeddings that can be used for wake word detection.
|
|
543
|
+
*
|
|
544
|
+
* @param {Object} melSpectogramOutput - The output tensor from a mel spectrogram model
|
|
545
|
+
* @param {Float32Array} melSpectogramOutput.data - The raw data from the mel spectrogram
|
|
546
|
+
* @param {Array<number>} melSpectogramOutput.dims - The dimensions of the mel spectrogram output
|
|
547
|
+
* @returns {Promise<Object>} - A promise that resolves to an ONNX tensor containing the speech embeddings
|
|
548
|
+
*/
|
|
549
|
+
async getEmbeddingFromMelSpectrogramOutput(melSpectogramOutput) {
|
|
550
|
+
const spectogramBuffer = await ONNX.createTensor(
|
|
551
|
+
"float32",
|
|
552
|
+
melSpectogramOutput.data,
|
|
553
|
+
melSpectogramOutput.dims.slice(2)
|
|
554
|
+
);
|
|
555
|
+
return this.run(spectogramBuffer);
|
|
556
|
+
}
|
|
557
|
+
/**
|
|
558
|
+
* Execute the model
|
|
559
|
+
* @param {Float32Array} input - Input data
|
|
560
|
+
* @returns {Promise} - Promise that resolves with the output of the model, which is a 2D array
|
|
561
|
+
* @throws {Error} - If the input data is not a Float32Array
|
|
562
|
+
*/
|
|
563
|
+
async execute(spectrograms) {
|
|
564
|
+
const [numFrames, melBins] = spectrograms.dims;
|
|
565
|
+
if (numFrames < this.windowSize) {
|
|
566
|
+
throw new Error(`Audio is too short to process - require ${this.windowSize} samples, got ${numFrames}`);
|
|
567
|
+
}
|
|
568
|
+
const numTruncatedFrames = numFrames - (numFrames - this.windowSize) % this.windowStride;
|
|
569
|
+
const numBatches = (numTruncatedFrames - this.windowSize) / this.windowStride + 1;
|
|
570
|
+
const embeddings = await ONNX.createTensor(
|
|
571
|
+
"float32",
|
|
572
|
+
new Array(numBatches * this.embeddingDim).fill(0),
|
|
573
|
+
[numBatches, this.embeddingDim]
|
|
574
|
+
);
|
|
575
|
+
const windowBatches = [];
|
|
576
|
+
for (let windowStart = 0; windowStart < numTruncatedFrames - this.windowSize + this.windowStride; windowStart += this.windowStride) {
|
|
577
|
+
const windowEnd = windowStart + this.windowSize;
|
|
578
|
+
const windowTensor = await ONNX.createTensor(
|
|
579
|
+
"float32",
|
|
580
|
+
spectrograms.data.slice(windowStart * melBins, windowEnd * melBins),
|
|
581
|
+
[this.windowSize, melBins, 1]
|
|
582
|
+
);
|
|
583
|
+
windowBatches.push([windowStart, windowEnd, windowTensor]);
|
|
584
|
+
}
|
|
585
|
+
const stackedWindowTensor = await ONNX.createTensor(
|
|
586
|
+
"float32",
|
|
587
|
+
new Float32Array(numBatches * this.windowSize * melBins),
|
|
588
|
+
[numBatches, this.windowSize, melBins, 1]
|
|
589
|
+
);
|
|
590
|
+
for (let i = 0; i < numBatches; i++) {
|
|
591
|
+
stackedWindowTensor.data.set(windowBatches[i][2].data, i * this.windowSize * melBins);
|
|
592
|
+
}
|
|
593
|
+
const output = await this.session.run({ input_1: stackedWindowTensor });
|
|
594
|
+
for (let i = 0; i < numBatches; i++) {
|
|
595
|
+
embeddings.data.set(
|
|
596
|
+
output.conv2d_19.data.slice(
|
|
597
|
+
i * this.embeddingDim,
|
|
598
|
+
(i + 1) * this.embeddingDim
|
|
599
|
+
),
|
|
600
|
+
i * this.embeddingDim
|
|
601
|
+
);
|
|
602
|
+
}
|
|
603
|
+
return embeddings;
|
|
604
|
+
}
|
|
605
|
+
};
|
|
606
|
+
|
|
607
|
+
// src/components/AI/HeyOzwell/WakeWord/lib/models/wake-word.js
|
|
608
|
+
var WakeWord = class extends ONNXModel {
|
|
609
|
+
/**
|
|
610
|
+
* Constructor
|
|
611
|
+
* @param {string} modelPath - Path to the ONNX model
|
|
612
|
+
* @param {number} threshold - Threshold for wake word detection (default: 0.5)
|
|
613
|
+
*/
|
|
614
|
+
constructor(modelPath, threshold, power = 0, webnn = 1, webgpu = 2, webgl = 3, wasm = 4) {
|
|
615
|
+
super(modelPath, power, webnn, webgpu, webgl, wasm);
|
|
616
|
+
this.threshold = threshold;
|
|
617
|
+
}
|
|
618
|
+
/**
|
|
619
|
+
* Test the model
|
|
620
|
+
* @param {boolean} debug - Whether to log debug messages
|
|
621
|
+
* @throws {Error} - If the model test fails
|
|
622
|
+
*/
|
|
623
|
+
async test(debug = false) {
|
|
624
|
+
const embeddings = await ONNX.createTensor(
|
|
625
|
+
"float32",
|
|
626
|
+
new Float32Array(16 * 96).fill(0),
|
|
627
|
+
[1, 16, 96]
|
|
628
|
+
);
|
|
629
|
+
const output = await this.run(embeddings);
|
|
630
|
+
if (0 <= output && output <= 1) {
|
|
631
|
+
if (debug) {
|
|
632
|
+
console.log(`Wake Word model OK, executed in ${this.duration} ms`);
|
|
633
|
+
}
|
|
634
|
+
} else {
|
|
635
|
+
throw new Error(`Wake Word model test failed - expected 0 <= x <= 1, got ${output}`);
|
|
636
|
+
}
|
|
637
|
+
}
|
|
638
|
+
/**
|
|
639
|
+
* Execute the model
|
|
640
|
+
* @param {Float32Array} embeddings - Input embeddings
|
|
641
|
+
* @returns {Promise} - Promise that resolves with the output of the model, which is a single float
|
|
642
|
+
* @throws {Error} - If the input data is not a Float32Array
|
|
643
|
+
*/
|
|
644
|
+
async execute(embeddings) {
|
|
645
|
+
const input = {};
|
|
646
|
+
if (embeddings.dims.length === 3) {
|
|
647
|
+
input.input = embeddings;
|
|
648
|
+
} else {
|
|
649
|
+
input.input = await ONNX.createTensor(
|
|
650
|
+
"float32",
|
|
651
|
+
embeddings.data,
|
|
652
|
+
[1, embeddings.dims[0], embeddings.dims[1]]
|
|
653
|
+
);
|
|
654
|
+
}
|
|
655
|
+
const output = await this.session.run(input);
|
|
656
|
+
return output.output.data[0] * 1;
|
|
657
|
+
}
|
|
658
|
+
/**
|
|
659
|
+
* Check if the wake word is detected based on the threshold
|
|
660
|
+
* @param {Float32Array} embeddings - Input embeddings
|
|
661
|
+
* @returns {Promise<Object>} - Promise that resolves with an object containing probability and detected status
|
|
662
|
+
*/
|
|
663
|
+
async checkWakeWordCalled(embeddings) {
|
|
664
|
+
const probability = await this.run(embeddings);
|
|
665
|
+
let thr = this.threshold;
|
|
666
|
+
const ov = typeof window !== "undefined" ? window.__baseThr : void 0;
|
|
667
|
+
if (typeof ov === "number") thr = ov;
|
|
668
|
+
else if (ov && this.name && typeof ov[this.name] === "number") thr = ov[this.name];
|
|
669
|
+
return {
|
|
670
|
+
probability,
|
|
671
|
+
detected: probability >= thr
|
|
672
|
+
};
|
|
673
|
+
}
|
|
674
|
+
/**
|
|
675
|
+
* Run wake word detection on audio.
|
|
676
|
+
* @param {Float32Array} embeddings - Input embeddings
|
|
677
|
+
* @returns {Promise} - Promise that resolves when wake word detection is complete.
|
|
678
|
+
*/
|
|
679
|
+
async checkWakeWordPresent(embeddings) {
|
|
680
|
+
return await this.execute(embeddings);
|
|
681
|
+
}
|
|
682
|
+
};
|
|
683
|
+
|
|
684
|
+
// src/components/AI/HeyOzwell/WakeWord/lib/hey-buddy.js
|
|
685
|
+
async function embeddingBufferArrayToEmbedding(embeddingBufferArray, numFramesPerEmbedding, embeddingDim) {
|
|
686
|
+
const combinedEmptyData = new Float32Array(numFramesPerEmbedding * embeddingBufferArray.length * embeddingDim);
|
|
687
|
+
const embeddingBuffer = await ONNX.createTensor(
|
|
688
|
+
"float32",
|
|
689
|
+
combinedEmptyData,
|
|
690
|
+
[numFramesPerEmbedding * embeddingBufferArray.length, embeddingDim]
|
|
691
|
+
);
|
|
692
|
+
for (let i = 0; i < embeddingBufferArray.length; i++) {
|
|
693
|
+
const embedding = embeddingBufferArray[i];
|
|
694
|
+
embeddingBuffer.data.set(embedding.data, i * numFramesPerEmbedding * embeddingDim);
|
|
695
|
+
}
|
|
696
|
+
return embeddingBuffer;
|
|
697
|
+
}
|
|
698
|
+
var HeyBuddy = class {
|
|
699
|
+
/**
|
|
700
|
+
* Create a HeyBuddy instance.
|
|
701
|
+
* @param {Object} [options] - Options object.
|
|
702
|
+
* @param {number} [options.positiveVadThreshold=0.5] - VAD threshold for speech.
|
|
703
|
+
* @param {number} [options.negativeVadThreshold=0.25] - VAD threshold for silence.
|
|
704
|
+
* @param {number} [options.negativeVadCount=8] - Number of negative VADs to trigger silence.
|
|
705
|
+
* @param {number} [options.wakeWordThreads=4] - Number of threads for wake word detection.
|
|
706
|
+
* @param {number} [options.wakeWordThreshold=0.5] - Wake word detection threshold.
|
|
707
|
+
* @param {string|string[]} [options.modelPath="/models/hey-buddy.onnx"] - Path to wake word model.
|
|
708
|
+
* @param {string} [options.vadModelPath="/pretrained/silero-vad.onnx"] - Path to VAD model.
|
|
709
|
+
* @param {string} [options.embeddingModelPath="/pretrained/speech-embedding.onnx"] - Path to speech embedding model.
|
|
710
|
+
* @param {string} [options.spectrogramModelPath="/pretrained/mel-spectrogram.onnx"] - Path to mel spectrogram model.
|
|
711
|
+
* @param {number} [options.batchSeconds=1.08] - Number of seconds per batch.
|
|
712
|
+
* @param {number} [options.batchIntervalSeconds=0.12] - Number of seconds between batches.
|
|
713
|
+
* @param {number} [options.targetSampleRate=16000] - Target sample rate for audio.
|
|
714
|
+
* @param {number} [options.spectrogramMelBins=32] - Number of mel bins for spectrogram.
|
|
715
|
+
* @param {number} [options.embeddingDim=96] - Dimension of speech embedding.
|
|
716
|
+
* @param {number} [options.embeddingWindowSize=76] - Window size for speech embedding.
|
|
717
|
+
* @param {number} [options.embeddingWindowStride=8] - Window stride for speech embedding.
|
|
718
|
+
*/
|
|
719
|
+
constructor(options) {
|
|
720
|
+
options = options || {};
|
|
721
|
+
this.debug = options.debug || false;
|
|
722
|
+
options.positiveVadThreshold = options.positiveVadThreshold || 0.65;
|
|
723
|
+
options.negativeVadThreshold = options.negativeVadThreshold || 0.4;
|
|
724
|
+
options.negativeVadCount = options.negativeVadCount || 8;
|
|
725
|
+
this.wakeWordThreads = options.wakeWordThreads || 4;
|
|
726
|
+
this.wakeWordThreshold = options.wakeWordThreshold || 0.5;
|
|
727
|
+
this.wakeWordThresholds = options.wakeWordThresholds || {};
|
|
728
|
+
this.wakeWordInterval = options.wakeWordInterval || 2;
|
|
729
|
+
const modelPath = options.modelPath || "/models/hey-buddy.onnx";
|
|
730
|
+
const modelArray = Array.isArray(modelPath) ? modelPath : [modelPath];
|
|
731
|
+
const vadModelPath = options.vadModelPath || "/pretrained/silero-vad.onnx";
|
|
732
|
+
const embeddingModelPath = options.embeddingModelPath || "/pretrained/speech-embedding.onnx";
|
|
733
|
+
const spectrogramModelPath = options.spectrogramModelPath || "/pretrained/mel-spectrogram.onnx";
|
|
734
|
+
const batchSeconds = options.batchSeconds || 1.08;
|
|
735
|
+
const batchIntervalSeconds = options.batchIntervalSeconds || 0.12;
|
|
736
|
+
const targetSampleRate = options.targetSampleRate || 16e3;
|
|
737
|
+
const spectrogramMelBins = options.spectrogramMelBins || 32;
|
|
738
|
+
const embeddingDim = options.embeddingDim || 96;
|
|
739
|
+
const embeddingWindowSize = options.embeddingWindowSize || 76;
|
|
740
|
+
const embeddingWindowStride = options.embeddingWindowStride || 8;
|
|
741
|
+
const wakeWordEmbeddingFrames = options.wakeWordEmbeddingFrames || 16;
|
|
742
|
+
this.vad = new SileroVAD(vadModelPath, targetSampleRate, options.positiveVadThreshold, options.negativeVadThreshold, options.negativeVadCount);
|
|
743
|
+
this.vad.test(this.debug).catch((e) => console.warn("[HeyBuddy] vad.test failed", e));
|
|
744
|
+
this.spectrogram = new MelSpectrogram(spectrogramModelPath);
|
|
745
|
+
this.spectrogram.test(this.debug).catch((e) => console.warn("[HeyBuddy] spectrogram.test failed", e));
|
|
746
|
+
this.spectrogramMelBins = spectrogramMelBins;
|
|
747
|
+
this.embedding = new SpeechEmbedding(
|
|
748
|
+
embeddingModelPath,
|
|
749
|
+
embeddingDim,
|
|
750
|
+
embeddingWindowSize,
|
|
751
|
+
embeddingWindowStride
|
|
752
|
+
);
|
|
753
|
+
this.embedding.test(this.debug).catch((e) => console.warn("[HeyBuddy] embedding.test failed", e));
|
|
754
|
+
this.embeddingDim = embeddingDim;
|
|
755
|
+
this.embeddingWindowSize = embeddingWindowSize;
|
|
756
|
+
this.embeddingWindowStride = embeddingWindowStride;
|
|
757
|
+
this.embeddingBuffer = null;
|
|
758
|
+
this.embeddingBufferArray = [];
|
|
759
|
+
this.voiceprints = {};
|
|
760
|
+
this.voiceprintThreshold = options.voiceprintThreshold ?? 0.85;
|
|
761
|
+
this.voiceprintThresholds = options.voiceprintThresholds || {};
|
|
762
|
+
this.voiceprintGate = options.voiceprintGate ?? 0.3;
|
|
763
|
+
this.embeddingMean = null;
|
|
764
|
+
this.voiceprintRecall = options.voiceprintRecall ?? true;
|
|
765
|
+
this.debounceFrames = options.debounceFrames ?? 1;
|
|
766
|
+
this._consec = {};
|
|
767
|
+
this.lastWakeProb = 0;
|
|
768
|
+
this._peakProb = {};
|
|
769
|
+
this._peakEmb = {};
|
|
770
|
+
this.lastWakeEmbedding = null;
|
|
771
|
+
this.wakeWords = {};
|
|
772
|
+
this.wakeWordTimes = {};
|
|
773
|
+
this.wakeWordEmbeddingFrames = wakeWordEmbeddingFrames;
|
|
774
|
+
for (let model of modelArray) {
|
|
775
|
+
let modelName = model.split("/").pop().split(".")[0];
|
|
776
|
+
let modelThreshold = this.wakeWordThresholds[modelName] ?? this.wakeWordThreshold;
|
|
777
|
+
this.wakeWords[modelName] = new WakeWord(model, modelThreshold);
|
|
778
|
+
this.wakeWords[modelName].name = modelName;
|
|
779
|
+
this.wakeWords[modelName].test(this.debug).catch((e) => console.warn(`[HeyBuddy] ${modelName}.test failed`, e));
|
|
780
|
+
}
|
|
781
|
+
this.recording = false;
|
|
782
|
+
this.audioBuffer = null;
|
|
783
|
+
this.frameIntervalEma = 0;
|
|
784
|
+
this.frameIntervalEmaWeight = 0.1;
|
|
785
|
+
this.frameTimeEma = 0;
|
|
786
|
+
this.frameTimeEmaWeight = 0.1;
|
|
787
|
+
this.speechStartCallbacks = [];
|
|
788
|
+
this.speechEndCallbacks = [];
|
|
789
|
+
this.recordingCallbacks = [];
|
|
790
|
+
this.processedCallbacks = [];
|
|
791
|
+
this.detectedCallbacks = [];
|
|
792
|
+
this.batcher = new AudioBatcher(
|
|
793
|
+
batchSeconds,
|
|
794
|
+
batchIntervalSeconds,
|
|
795
|
+
targetSampleRate
|
|
796
|
+
);
|
|
797
|
+
this.batcher.onBatch((batch) => this.process(batch));
|
|
798
|
+
}
|
|
799
|
+
/**
|
|
800
|
+
* Set a user's enrolled voiceprint for a wake word.
|
|
801
|
+
* @param {string} name - Wake-word name (e.g. "hey-ozwell").
|
|
802
|
+
* @param {Float32Array[]} vectors - Flattened embedding windows captured at enrollment.
|
|
803
|
+
*/
|
|
804
|
+
setVoiceprint(name, vectors) {
|
|
805
|
+
this.voiceprints[name] = vectors || [];
|
|
806
|
+
}
|
|
807
|
+
clearVoiceprint(name) {
|
|
808
|
+
delete this.voiceprints[name];
|
|
809
|
+
}
|
|
810
|
+
hasVoiceprint(name) {
|
|
811
|
+
return Array.isArray(this.voiceprints[name]) && this.voiceprints[name].length > 0;
|
|
812
|
+
}
|
|
813
|
+
/**
|
|
814
|
+
* Max cosine similarity between a live embedding window and the stored voiceprint set.
|
|
815
|
+
* Cosine compares DIRECTION not magnitude, so it ignores loudness and keys on what the
|
|
816
|
+
* sound actually is. Returns 0..1 (1 = near-identical).
|
|
817
|
+
*/
|
|
818
|
+
voiceprintSimilarity(name, liveVec) {
|
|
819
|
+
const set = this.voiceprints[name];
|
|
820
|
+
if (!Array.isArray(set) || set.length === 0 || !liveVec) return 0;
|
|
821
|
+
const mean = this.embeddingMean;
|
|
822
|
+
let best = -1;
|
|
823
|
+
for (const ref of set) {
|
|
824
|
+
let dot = 0, na = 0, nb = 0;
|
|
825
|
+
const n = Math.min(ref.length, liveVec.length);
|
|
826
|
+
for (let i = 0; i < n; i++) {
|
|
827
|
+
const a = mean ? ref[i] - mean[i] : ref[i];
|
|
828
|
+
const b = mean ? liveVec[i] - mean[i] : liveVec[i];
|
|
829
|
+
dot += a * b;
|
|
830
|
+
na += a * a;
|
|
831
|
+
nb += b * b;
|
|
832
|
+
}
|
|
833
|
+
if (na > 0 && nb > 0) {
|
|
834
|
+
const s = dot / (Math.sqrt(na) * Math.sqrt(nb));
|
|
835
|
+
if (s > best) best = s;
|
|
836
|
+
}
|
|
837
|
+
}
|
|
838
|
+
return best;
|
|
839
|
+
}
|
|
840
|
+
/**
|
|
841
|
+
* Gets the names of wake words, chunked for threaded wake word detection.
|
|
842
|
+
* @returns {string[][]} - Names of wake words.
|
|
843
|
+
*/
|
|
844
|
+
get chunkedWakeWords() {
|
|
845
|
+
return Object.keys(this.wakeWords).reduce((carry, name, i) => {
|
|
846
|
+
const chunkIndex = Math.floor(i / this.wakeWordThreads);
|
|
847
|
+
if (!carry[chunkIndex]) {
|
|
848
|
+
carry[chunkIndex] = [];
|
|
849
|
+
}
|
|
850
|
+
carry[chunkIndex].push(name);
|
|
851
|
+
return carry;
|
|
852
|
+
}, []);
|
|
853
|
+
}
|
|
854
|
+
/**
|
|
855
|
+
* Add a callback for when a wake word is detected.
|
|
856
|
+
* @param {string|string[]} names - Name of wake word.
|
|
857
|
+
* @param {Function} callback - Callback function.
|
|
858
|
+
*/
|
|
859
|
+
onDetected(names, callback) {
|
|
860
|
+
this.detectedCallbacks.push({ names, callback });
|
|
861
|
+
}
|
|
862
|
+
/**
|
|
863
|
+
* Add a callback for processed data.
|
|
864
|
+
* @param {Function} callback - Callback function.
|
|
865
|
+
*/
|
|
866
|
+
onProcessed(callback) {
|
|
867
|
+
this.processedCallbacks.push(callback);
|
|
868
|
+
}
|
|
869
|
+
/**
|
|
870
|
+
* Add a callback for speech start.
|
|
871
|
+
* @param {Function} callback - Callback function.
|
|
872
|
+
*/
|
|
873
|
+
onSpeechStart(callback) {
|
|
874
|
+
this.speechStartCallbacks.push(callback);
|
|
875
|
+
}
|
|
876
|
+
/**
|
|
877
|
+
* Add a callback for speech end.
|
|
878
|
+
* @param {Function} callback - Callback function.
|
|
879
|
+
*/
|
|
880
|
+
onSpeechEnd(callback) {
|
|
881
|
+
this.speechEndCallbacks.push(callback);
|
|
882
|
+
}
|
|
883
|
+
/**
|
|
884
|
+
* Add a callback for recording.
|
|
885
|
+
* @param {Function} callback - Callback function.
|
|
886
|
+
*/
|
|
887
|
+
onRecording(callback) {
|
|
888
|
+
this.recordingCallbacks.push(callback);
|
|
889
|
+
}
|
|
890
|
+
/**
|
|
891
|
+
* Trigger speech start event.
|
|
892
|
+
*/
|
|
893
|
+
speechStart() {
|
|
894
|
+
if (this.debug) {
|
|
895
|
+
console.log("Speech start");
|
|
896
|
+
}
|
|
897
|
+
for (let callback of this.speechStartCallbacks) {
|
|
898
|
+
callback();
|
|
899
|
+
}
|
|
900
|
+
}
|
|
901
|
+
/**
|
|
902
|
+
* Trigger speech end event.
|
|
903
|
+
*/
|
|
904
|
+
speechEnd() {
|
|
905
|
+
if (this.debug) {
|
|
906
|
+
console.log("Speech end");
|
|
907
|
+
}
|
|
908
|
+
for (let callback of this.speechEndCallbacks) {
|
|
909
|
+
callback();
|
|
910
|
+
}
|
|
911
|
+
if (this.recording) {
|
|
912
|
+
this.dispatchRecording();
|
|
913
|
+
this.recording = false;
|
|
914
|
+
}
|
|
915
|
+
}
|
|
916
|
+
/**
|
|
917
|
+
* Dispatch recording to all recording callbacks.
|
|
918
|
+
*/
|
|
919
|
+
dispatchRecording() {
|
|
920
|
+
if (this.audioBuffer === null) {
|
|
921
|
+
console.error("No recording to dispatch");
|
|
922
|
+
return;
|
|
923
|
+
}
|
|
924
|
+
if (this.debug) {
|
|
925
|
+
const recordingLength = this.audioBuffer.length;
|
|
926
|
+
const recordedDuration = recordingLength / this.batcher.targetSampleRate;
|
|
927
|
+
console.log(`Dispatching recording with ${recordingLength} frames (${recordedDuration} s)`);
|
|
928
|
+
}
|
|
929
|
+
for (let callback of this.recordingCallbacks) {
|
|
930
|
+
callback(this.audioBuffer);
|
|
931
|
+
}
|
|
932
|
+
this.audioBuffer = null;
|
|
933
|
+
}
|
|
934
|
+
/**
|
|
935
|
+
* Trigger wake word detection event.
|
|
936
|
+
* @param {string} name - Name of wake word.
|
|
937
|
+
*/
|
|
938
|
+
wakeWordDetected(name) {
|
|
939
|
+
const now = Date.now();
|
|
940
|
+
if (this.wakeWordTimes[name] && now - this.wakeWordTimes[name] < this.wakeWordInterval * 1e3) {
|
|
941
|
+
return;
|
|
942
|
+
}
|
|
943
|
+
if (this.debug) {
|
|
944
|
+
console.log("Wake word detected:", name);
|
|
945
|
+
}
|
|
946
|
+
this.recording = true;
|
|
947
|
+
this.wakeWordTimes[name] = now;
|
|
948
|
+
const frameSec = this.batcher && this.batcher.batchIntervalSamples && this.batcher.targetSampleRate ? this.batcher.batchIntervalSamples / this.batcher.targetSampleRate : 0.12;
|
|
949
|
+
this.lastWakeDurationSec = (this._consec[name] || 0) * frameSec;
|
|
950
|
+
this.lastWakeEmbedding = this._peakEmb && this._peakEmb[name] ? this._peakEmb[name] : this.embeddingBuffer && this.embeddingBuffer.data ? Float32Array.from(this.embeddingBuffer.data) : null;
|
|
951
|
+
this.lastWakeProb = this._peakProb && this._peakProb[name] ? this._peakProb[name] : 0;
|
|
952
|
+
for (let { names, callback } of this.detectedCallbacks) {
|
|
953
|
+
if (Array.isArray(names) && names.includes(name) || names === name) {
|
|
954
|
+
callback();
|
|
955
|
+
}
|
|
956
|
+
}
|
|
957
|
+
}
|
|
958
|
+
/**
|
|
959
|
+
* Trigger processed event.
|
|
960
|
+
* @param {Object} data - Processed data.
|
|
961
|
+
*/
|
|
962
|
+
processed(data) {
|
|
963
|
+
for (let callback of this.processedCallbacks) {
|
|
964
|
+
callback(data);
|
|
965
|
+
}
|
|
966
|
+
}
|
|
967
|
+
/**
|
|
968
|
+
* Runs wake word detection on a subset of wake words.
|
|
969
|
+
* @param {string[]} wakeWordNames - Names of wake words to check.
|
|
970
|
+
* @returns {Promise} - Promise that resolves when wake word detection is complete.
|
|
971
|
+
*/
|
|
972
|
+
async checkWakeWordSubset(wakeWordNames) {
|
|
973
|
+
return await Promise.all(
|
|
974
|
+
wakeWordNames.map((name) => this.wakeWords[name].checkWakeWordCalled(this.embeddingBuffer))
|
|
975
|
+
);
|
|
976
|
+
}
|
|
977
|
+
/**
|
|
978
|
+
* Run wake word detection on audio.
|
|
979
|
+
* @returns {Promise} - Promise that resolves when wake word detection is complete.
|
|
980
|
+
*/
|
|
981
|
+
async checkWakeWords() {
|
|
982
|
+
const returnMap = {};
|
|
983
|
+
for (let nameChunk of this.chunkedWakeWords) {
|
|
984
|
+
const wakeWordsCalled = await this.checkWakeWordSubset(nameChunk);
|
|
985
|
+
for (let i = 0; i < nameChunk.length; i++) {
|
|
986
|
+
const name = nameChunk[i];
|
|
987
|
+
const wordCalled = wakeWordsCalled[i];
|
|
988
|
+
returnMap[name] = wordCalled;
|
|
989
|
+
}
|
|
990
|
+
}
|
|
991
|
+
const liveVec = this.embeddingBuffer ? this.embeddingBuffer.data : null;
|
|
992
|
+
for (let name in returnMap) {
|
|
993
|
+
returnMap[name].voiceprintSim = this.hasVoiceprint(name) ? this.voiceprintSimilarity(name, liveVec) : 0;
|
|
994
|
+
}
|
|
995
|
+
for (let name in returnMap) {
|
|
996
|
+
if (returnMap[name].detected) {
|
|
997
|
+
this._consec[name] = (this._consec[name] || 0) + 1;
|
|
998
|
+
if (returnMap[name].probability > (this._peakProb[name] || 0) && this.embeddingBuffer && this.embeddingBuffer.data) {
|
|
999
|
+
this._peakProb[name] = returnMap[name].probability;
|
|
1000
|
+
this._peakEmb[name] = Float32Array.from(this.embeddingBuffer.data);
|
|
1001
|
+
}
|
|
1002
|
+
} else {
|
|
1003
|
+
this._consec[name] = 0;
|
|
1004
|
+
this._peakProb[name] = 0;
|
|
1005
|
+
this._peakEmb[name] = null;
|
|
1006
|
+
}
|
|
1007
|
+
}
|
|
1008
|
+
const minRunFor = (name) => {
|
|
1009
|
+
if (typeof window.__debounceFrames === "number") return window.__debounceFrames;
|
|
1010
|
+
if (this.debounceFrames && typeof this.debounceFrames === "object") return this.debounceFrames[name] ?? 1;
|
|
1011
|
+
return this.debounceFrames || 1;
|
|
1012
|
+
};
|
|
1013
|
+
let best = null;
|
|
1014
|
+
for (let name in returnMap) {
|
|
1015
|
+
if (returnMap[name].detected && this._consec[name] >= minRunFor(name)) {
|
|
1016
|
+
const prob = returnMap[name].probability;
|
|
1017
|
+
if (best === null || prob > best.prob) {
|
|
1018
|
+
best = { name, prob };
|
|
1019
|
+
}
|
|
1020
|
+
}
|
|
1021
|
+
}
|
|
1022
|
+
if (best !== null) {
|
|
1023
|
+
this.wakeWordDetected(best.name);
|
|
1024
|
+
} else if (this.voiceprintRecall) {
|
|
1025
|
+
let vbest = null;
|
|
1026
|
+
for (let name in returnMap) {
|
|
1027
|
+
const sim = returnMap[name].voiceprintSim;
|
|
1028
|
+
const vt = this.voiceprintThresholds[name] ?? this.voiceprintThreshold;
|
|
1029
|
+
const modelLit = returnMap[name].probability >= this.voiceprintGate;
|
|
1030
|
+
if (sim >= vt && modelLit && (vbest === null || sim > vbest.sim)) vbest = { name, sim };
|
|
1031
|
+
}
|
|
1032
|
+
if (vbest !== null) {
|
|
1033
|
+
this.wakeWordDetected(vbest.name);
|
|
1034
|
+
}
|
|
1035
|
+
}
|
|
1036
|
+
return returnMap;
|
|
1037
|
+
}
|
|
1038
|
+
/**
|
|
1039
|
+
* Process audio batch.
|
|
1040
|
+
* @param {Float32Array} audio - Audio samples.
|
|
1041
|
+
*/
|
|
1042
|
+
async process(audio) {
|
|
1043
|
+
this.frameStart = (/* @__PURE__ */ new Date()).getTime();
|
|
1044
|
+
if (this.frameEnd !== void 0 && this.frameEnd !== null) {
|
|
1045
|
+
this.frameInterval = this.frameStart - this.frameEnd;
|
|
1046
|
+
} else {
|
|
1047
|
+
this.frameInterval = 0;
|
|
1048
|
+
}
|
|
1049
|
+
if (this.frameIntervalEma === 0) {
|
|
1050
|
+
this.frameIntervalEma = this.frameInterval;
|
|
1051
|
+
} else {
|
|
1052
|
+
this.frameIntervalEma = this.frameIntervalEma * (1 - this.frameIntervalEmaWeight) + this.frameInterval * this.frameIntervalEmaWeight;
|
|
1053
|
+
}
|
|
1054
|
+
const lastBatch = audio.subarray(audio.length - this.batcher.batchIntervalSamples);
|
|
1055
|
+
const spectrograms = await this.spectrogram.run(audio);
|
|
1056
|
+
const embedding = await this.embedding.getEmbeddingFromMelSpectrogramOutput(spectrograms);
|
|
1057
|
+
const numFramesPerEmbedding = embedding.dims[0];
|
|
1058
|
+
const maxEmbeddings = this.wakeWordEmbeddingFrames / numFramesPerEmbedding;
|
|
1059
|
+
this.embeddingBufferArray.push(embedding);
|
|
1060
|
+
if (this.embeddingBufferArray.length > maxEmbeddings) this.embeddingBufferArray.shift();
|
|
1061
|
+
this.embeddingBuffer = await embeddingBufferArrayToEmbedding(this.embeddingBufferArray, numFramesPerEmbedding, this.embeddingDim);
|
|
1062
|
+
const { isSpeaking, speechProbability, justStoppedSpeaking, justStartedSpeaking } = await this.vad.hasSpeechAudio(lastBatch);
|
|
1063
|
+
if (!isSpeaking && this.embeddingBuffer && this.embeddingBuffer.data) {
|
|
1064
|
+
const d = this.embeddingBuffer.data;
|
|
1065
|
+
if (!this.embeddingMean || this.embeddingMean.length !== d.length) this.embeddingMean = Float32Array.from(d);
|
|
1066
|
+
else for (let i = 0; i < d.length; i++) this.embeddingMean[i] += 0.02 * (d[i] - this.embeddingMean[i]);
|
|
1067
|
+
}
|
|
1068
|
+
if (justStartedSpeaking) this.speechStart();
|
|
1069
|
+
if (justStoppedSpeaking) this.speechEnd();
|
|
1070
|
+
if (isSpeaking && this.embeddingBuffer.dims[0] === this.wakeWordEmbeddingFrames && !this._wakeBusy) {
|
|
1071
|
+
this._wakeBusy = true;
|
|
1072
|
+
let wakeWordsCalled;
|
|
1073
|
+
try {
|
|
1074
|
+
wakeWordsCalled = await this.checkWakeWords();
|
|
1075
|
+
} finally {
|
|
1076
|
+
this._wakeBusy = false;
|
|
1077
|
+
}
|
|
1078
|
+
this.processed({
|
|
1079
|
+
listening: true,
|
|
1080
|
+
recording: this.recording,
|
|
1081
|
+
speech: { probability: speechProbability, active: isSpeaking },
|
|
1082
|
+
wakeWords: wakeWordsCalled,
|
|
1083
|
+
// Live [16x96] embedding window (flattened) — what enrollment captures and
|
|
1084
|
+
// what the voiceprint layer matches against. Present only while listening.
|
|
1085
|
+
embedding: this.embeddingBuffer ? this.embeddingBuffer.data : null
|
|
1086
|
+
});
|
|
1087
|
+
} else {
|
|
1088
|
+
this.processed({
|
|
1089
|
+
listening: false,
|
|
1090
|
+
recording: this.recording,
|
|
1091
|
+
speech: { probability: speechProbability, active: isSpeaking },
|
|
1092
|
+
wakeWords: Object.entries(this.wakeWords).reduce(
|
|
1093
|
+
(carry, [name, model]) => {
|
|
1094
|
+
carry[name] = {
|
|
1095
|
+
probability: 0,
|
|
1096
|
+
active: false
|
|
1097
|
+
};
|
|
1098
|
+
return carry;
|
|
1099
|
+
},
|
|
1100
|
+
{}
|
|
1101
|
+
),
|
|
1102
|
+
embedding: this.embeddingBuffer ? this.embeddingBuffer.data : null
|
|
1103
|
+
});
|
|
1104
|
+
}
|
|
1105
|
+
if (this.recording) {
|
|
1106
|
+
if (this.audioBuffer === null) {
|
|
1107
|
+
this.audioBuffer = new Float32Array(audio.length);
|
|
1108
|
+
this.audioBuffer.set(audio);
|
|
1109
|
+
} else {
|
|
1110
|
+
const concatenated = new Float32Array(this.audioBuffer.length + lastBatch.length);
|
|
1111
|
+
concatenated.set(this.audioBuffer);
|
|
1112
|
+
concatenated.set(lastBatch, this.audioBuffer.length);
|
|
1113
|
+
this.audioBuffer = concatenated;
|
|
1114
|
+
}
|
|
1115
|
+
}
|
|
1116
|
+
this.frameEnd = (/* @__PURE__ */ new Date()).getTime();
|
|
1117
|
+
this.frameTime = this.frameEnd - this.frameStart;
|
|
1118
|
+
if (this.frameTimeEma === 0) {
|
|
1119
|
+
this.frameTimeEma = this.frameTime;
|
|
1120
|
+
} else {
|
|
1121
|
+
this.frameTimeEma = this.frameTimeEma * (1 - this.frameTimeEmaWeight) + this.frameTime * this.frameTimeEmaWeight;
|
|
1122
|
+
}
|
|
1123
|
+
}
|
|
1124
|
+
};
|
|
1125
|
+
if (typeof window !== "undefined") {
|
|
1126
|
+
window.HeyBuddy = HeyBuddy;
|
|
1127
|
+
}
|
|
1128
|
+
|
|
1129
|
+
export { HeyBuddy };
|
|
1130
|
+
//# sourceMappingURL=hey-buddy-NMSWZ4TN.js.map
|
|
1131
|
+
//# sourceMappingURL=hey-buddy-NMSWZ4TN.js.map
|