@sorisdk/web-audio 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +98 -0
- package/dist/generated/window.d.ts +12 -0
- package/dist/generated/window.js +9 -0
- package/dist/generated/window_bg.js +364 -0
- package/dist/generated/window_bg.wasm +0 -0
- package/dist/generated/window_bg.wasm.d.ts +15 -0
- package/dist/index.d.ts +241 -0
- package/dist/index.js +1217 -0
- package/package.json +59 -0
package/dist/index.js
ADDED
|
@@ -0,0 +1,1217 @@
|
|
|
1
|
+
// src/auth-cache.ts
|
|
2
|
+
import {
|
|
3
|
+
initMatcher
|
|
4
|
+
} from "@sorisdk/matcher";
|
|
5
|
+
var DEFAULT_BROWSER_AUDIOPACK_VERSION = "20220401000000";
|
|
6
|
+
function formatUtcVersion(date) {
|
|
7
|
+
const year = date.getUTCFullYear().toString().padStart(4, "0");
|
|
8
|
+
const month = String(date.getUTCMonth() + 1).padStart(2, "0");
|
|
9
|
+
const day = String(date.getUTCDate()).padStart(2, "0");
|
|
10
|
+
const hours = String(date.getUTCHours()).padStart(2, "0");
|
|
11
|
+
const minutes = String(date.getUTCMinutes()).padStart(2, "0");
|
|
12
|
+
const seconds = String(date.getUTCSeconds()).padStart(2, "0");
|
|
13
|
+
return `${year}${month}${day}${hours}${minutes}${seconds}`;
|
|
14
|
+
}
|
|
15
|
+
function isRecord(value) {
|
|
16
|
+
return typeof value === "object" && value !== null;
|
|
17
|
+
}
|
|
18
|
+
function isAudioPackState(value) {
|
|
19
|
+
return isRecord(value) && typeof value.version === "string" && value.version.length > 0 && typeof value.packUrl === "string" && value.packUrl.length > 0;
|
|
20
|
+
}
|
|
21
|
+
function resolveStorage(storage) {
|
|
22
|
+
if (storage) {
|
|
23
|
+
return storage;
|
|
24
|
+
}
|
|
25
|
+
if (typeof globalThis.localStorage === "undefined") {
|
|
26
|
+
throw new Error("localStorage is not available in this environment");
|
|
27
|
+
}
|
|
28
|
+
return globalThis.localStorage;
|
|
29
|
+
}
|
|
30
|
+
function createLocalStorageAudioPackStore(options) {
|
|
31
|
+
const storage = resolveStorage(options.storage);
|
|
32
|
+
const { key } = options;
|
|
33
|
+
return {
|
|
34
|
+
load() {
|
|
35
|
+
const raw = storage.getItem(key);
|
|
36
|
+
if (!raw) {
|
|
37
|
+
return null;
|
|
38
|
+
}
|
|
39
|
+
try {
|
|
40
|
+
const parsed = JSON.parse(raw);
|
|
41
|
+
if (isAudioPackState(parsed)) {
|
|
42
|
+
return parsed;
|
|
43
|
+
}
|
|
44
|
+
} catch {
|
|
45
|
+
}
|
|
46
|
+
storage.removeItem(key);
|
|
47
|
+
return null;
|
|
48
|
+
},
|
|
49
|
+
save(state) {
|
|
50
|
+
storage.setItem(key, JSON.stringify(state));
|
|
51
|
+
},
|
|
52
|
+
clear() {
|
|
53
|
+
storage.removeItem(key);
|
|
54
|
+
}
|
|
55
|
+
};
|
|
56
|
+
}
|
|
57
|
+
function ensureAuthResult(result) {
|
|
58
|
+
if (!result) {
|
|
59
|
+
throw new Error("Expected auth result from initMatcher({ auth })");
|
|
60
|
+
}
|
|
61
|
+
return result;
|
|
62
|
+
}
|
|
63
|
+
async function authenticateAndLoadAudioPack(options) {
|
|
64
|
+
const cachedState = await options.store.load();
|
|
65
|
+
const requestedVersion = cachedState?.version ?? options.initialVersion ?? DEFAULT_BROWSER_AUDIOPACK_VERSION;
|
|
66
|
+
const authResult = ensureAuthResult(
|
|
67
|
+
await initMatcher({
|
|
68
|
+
...options.matcherInit,
|
|
69
|
+
auth: {
|
|
70
|
+
...options.auth,
|
|
71
|
+
version: requestedVersion
|
|
72
|
+
}
|
|
73
|
+
})
|
|
74
|
+
);
|
|
75
|
+
const canReuseCachedPack = authResult.forceUpdate === false && authResult.updated === 0 && cachedState?.packUrl !== void 0;
|
|
76
|
+
const loadedPackUrl = canReuseCachedPack ? cachedState.packUrl : authResult.packUrl;
|
|
77
|
+
if (!loadedPackUrl) {
|
|
78
|
+
throw new Error("Auth response did not include packUrl and no cached packUrl is available");
|
|
79
|
+
}
|
|
80
|
+
await options.loader.loadPack(loadedPackUrl);
|
|
81
|
+
const persistedVersion = canReuseCachedPack ? requestedVersion : formatUtcVersion(/* @__PURE__ */ new Date());
|
|
82
|
+
await options.store.save({
|
|
83
|
+
version: persistedVersion,
|
|
84
|
+
packUrl: loadedPackUrl
|
|
85
|
+
});
|
|
86
|
+
return {
|
|
87
|
+
...authResult,
|
|
88
|
+
requestedVersion,
|
|
89
|
+
loadedPackUrl,
|
|
90
|
+
persistedVersion,
|
|
91
|
+
source: canReuseCachedPack ? "cache" : "network",
|
|
92
|
+
cachedState
|
|
93
|
+
};
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// src/events.ts
|
|
97
|
+
var TypedEventEmitter = class {
|
|
98
|
+
listeners = /* @__PURE__ */ new Map();
|
|
99
|
+
on(eventName, listener) {
|
|
100
|
+
const bucket = this.listeners.get(eventName) ?? /* @__PURE__ */ new Set();
|
|
101
|
+
bucket.add(listener);
|
|
102
|
+
this.listeners.set(eventName, bucket);
|
|
103
|
+
return this;
|
|
104
|
+
}
|
|
105
|
+
off(eventName, listener) {
|
|
106
|
+
const bucket = this.listeners.get(eventName);
|
|
107
|
+
if (!bucket) {
|
|
108
|
+
return this;
|
|
109
|
+
}
|
|
110
|
+
bucket.delete(listener);
|
|
111
|
+
if (bucket.size === 0) {
|
|
112
|
+
this.listeners.delete(eventName);
|
|
113
|
+
}
|
|
114
|
+
return this;
|
|
115
|
+
}
|
|
116
|
+
once(eventName, listener) {
|
|
117
|
+
const wrapped = (payload) => {
|
|
118
|
+
this.off(eventName, wrapped);
|
|
119
|
+
listener(payload);
|
|
120
|
+
};
|
|
121
|
+
return this.on(eventName, wrapped);
|
|
122
|
+
}
|
|
123
|
+
emit(eventName, payload) {
|
|
124
|
+
const bucket = this.listeners.get(eventName);
|
|
125
|
+
if (!bucket) {
|
|
126
|
+
return;
|
|
127
|
+
}
|
|
128
|
+
for (const listener of bucket) {
|
|
129
|
+
listener(payload);
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
clear() {
|
|
133
|
+
this.listeners.clear();
|
|
134
|
+
}
|
|
135
|
+
};
|
|
136
|
+
|
|
137
|
+
// src/microphone-matcher.ts
|
|
138
|
+
import { createExtractor, initAfpgen } from "@sorisdk/afpgen";
|
|
139
|
+
import {
|
|
140
|
+
MatcherSession,
|
|
141
|
+
initMatcher as initMatcher2
|
|
142
|
+
} from "@sorisdk/matcher";
|
|
143
|
+
|
|
144
|
+
// src/internal.ts
|
|
145
|
+
import { AudioFingerprintType } from "@sorisdk/matcher";
|
|
146
|
+
var TARGET_SAMPLE_RATE = 16e3;
|
|
147
|
+
var DEFAULT_RUNTIME_CONFIG = {
|
|
148
|
+
captureBufferSize: 4096,
|
|
149
|
+
matchWindowMs: 3250,
|
|
150
|
+
matchStrideMs: 1e3,
|
|
151
|
+
afpgenConfig: {
|
|
152
|
+
hopSize: 1e3,
|
|
153
|
+
frameSize: 2,
|
|
154
|
+
radius: 4,
|
|
155
|
+
levelCount: 2
|
|
156
|
+
},
|
|
157
|
+
matchConfig: void 0,
|
|
158
|
+
initialFingerprintType: AudioFingerprintType.Livestream
|
|
159
|
+
};
|
|
160
|
+
function createBrowserAudioContext() {
|
|
161
|
+
const AudioContextConstructor = globalThis.AudioContext;
|
|
162
|
+
if (typeof AudioContextConstructor !== "function") {
|
|
163
|
+
throw new Error("AudioContext is not available in this environment");
|
|
164
|
+
}
|
|
165
|
+
try {
|
|
166
|
+
return new AudioContextConstructor({
|
|
167
|
+
sampleRate: TARGET_SAMPLE_RATE,
|
|
168
|
+
latencyHint: "interactive"
|
|
169
|
+
});
|
|
170
|
+
} catch {
|
|
171
|
+
return new AudioContextConstructor();
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
function getBrowserMediaDevices() {
|
|
175
|
+
const mediaDevices = globalThis.navigator?.mediaDevices;
|
|
176
|
+
if (!mediaDevices?.getUserMedia) {
|
|
177
|
+
throw new Error("MediaDevices.getUserMedia is not available in this environment");
|
|
178
|
+
}
|
|
179
|
+
return mediaDevices;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
// src/match-window.ts
|
|
183
|
+
var cachedBindings = null;
|
|
184
|
+
var initPromise = null;
|
|
185
|
+
var testInjectedWasmModule = null;
|
|
186
|
+
function getCallable(obj, names) {
|
|
187
|
+
for (const name of names) {
|
|
188
|
+
const candidate = obj[name];
|
|
189
|
+
if (typeof candidate === "function") {
|
|
190
|
+
return candidate;
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
return null;
|
|
194
|
+
}
|
|
195
|
+
var defaultLoader = async () => {
|
|
196
|
+
const generatedModuleUrl = new URL("./generated/window.js", import.meta.url);
|
|
197
|
+
return import(
|
|
198
|
+
/* @vite-ignore */
|
|
199
|
+
generatedModuleUrl.href
|
|
200
|
+
);
|
|
201
|
+
};
|
|
202
|
+
var WrappedFingerprintMatchWindow = class {
|
|
203
|
+
constructor(inner) {
|
|
204
|
+
this.inner = inner;
|
|
205
|
+
}
|
|
206
|
+
appendFingerprint(bytes) {
|
|
207
|
+
const fn = getCallable(this.inner, ["appendFingerprint", "append_fingerprint"]);
|
|
208
|
+
if (!fn) {
|
|
209
|
+
throw new Error("web-audio wasm window is missing appendFingerprint");
|
|
210
|
+
}
|
|
211
|
+
fn.call(this.inner, bytes);
|
|
212
|
+
}
|
|
213
|
+
hasReadyQuery() {
|
|
214
|
+
const fn = getCallable(this.inner, ["hasReadyQuery", "has_ready_query"]);
|
|
215
|
+
if (!fn) {
|
|
216
|
+
throw new Error("web-audio wasm window is missing hasReadyQuery");
|
|
217
|
+
}
|
|
218
|
+
return Boolean(fn.call(this.inner));
|
|
219
|
+
}
|
|
220
|
+
takeReadyQuery() {
|
|
221
|
+
const fn = getCallable(this.inner, ["takeReadyQuery", "take_ready_query"]);
|
|
222
|
+
if (!fn) {
|
|
223
|
+
throw new Error("web-audio wasm window is missing takeReadyQuery");
|
|
224
|
+
}
|
|
225
|
+
const result = fn.call(this.inner);
|
|
226
|
+
return result instanceof Uint8Array ? result : result ? new Uint8Array(result) : null;
|
|
227
|
+
}
|
|
228
|
+
reset() {
|
|
229
|
+
const fn = getCallable(this.inner, ["reset"]);
|
|
230
|
+
if (!fn) {
|
|
231
|
+
throw new Error("web-audio wasm window is missing reset");
|
|
232
|
+
}
|
|
233
|
+
fn.call(this.inner);
|
|
234
|
+
}
|
|
235
|
+
destroy() {
|
|
236
|
+
const fn = getCallable(this.inner, ["destroy", "free"]);
|
|
237
|
+
if (fn) {
|
|
238
|
+
fn.call(this.inner);
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
};
|
|
242
|
+
function createBindingsFromModule(moduleLike) {
|
|
243
|
+
if (!moduleLike || typeof moduleLike !== "object") {
|
|
244
|
+
throw new Error("Invalid web-audio wasm module");
|
|
245
|
+
}
|
|
246
|
+
const mod = moduleLike;
|
|
247
|
+
const initFn = getCallable(mod, ["initWebAudioWindow"]) ?? (typeof mod.default === "function" ? mod.default.bind(mod) : null);
|
|
248
|
+
const WindowCtor = mod.WasmFingerprintMatchWindow ?? mod.FingerprintMatchWindow;
|
|
249
|
+
if (!WindowCtor) {
|
|
250
|
+
throw new Error("web-audio wasm module is missing window export");
|
|
251
|
+
}
|
|
252
|
+
return {
|
|
253
|
+
async init(initInput) {
|
|
254
|
+
if (initFn) {
|
|
255
|
+
await Promise.resolve(initFn(initInput));
|
|
256
|
+
}
|
|
257
|
+
},
|
|
258
|
+
createWindow(options) {
|
|
259
|
+
return new WrappedFingerprintMatchWindow(
|
|
260
|
+
new WindowCtor(
|
|
261
|
+
options.sampleRate,
|
|
262
|
+
options.afpgenConfig,
|
|
263
|
+
options.matchWindowMs,
|
|
264
|
+
options.matchStrideMs
|
|
265
|
+
)
|
|
266
|
+
);
|
|
267
|
+
}
|
|
268
|
+
};
|
|
269
|
+
}
|
|
270
|
+
async function initMatchWindowWasm(options) {
|
|
271
|
+
if (cachedBindings) {
|
|
272
|
+
return;
|
|
273
|
+
}
|
|
274
|
+
if (!initPromise) {
|
|
275
|
+
initPromise = (async () => {
|
|
276
|
+
try {
|
|
277
|
+
const loader = options?.loader ?? defaultLoader;
|
|
278
|
+
const moduleLike = testInjectedWasmModule ?? await loader();
|
|
279
|
+
const bindings = createBindingsFromModule(moduleLike);
|
|
280
|
+
await bindings.init(options?.wasmInitInput);
|
|
281
|
+
cachedBindings = bindings;
|
|
282
|
+
return bindings;
|
|
283
|
+
} catch (error) {
|
|
284
|
+
initPromise = null;
|
|
285
|
+
cachedBindings = null;
|
|
286
|
+
throw error;
|
|
287
|
+
}
|
|
288
|
+
})();
|
|
289
|
+
}
|
|
290
|
+
await initPromise;
|
|
291
|
+
}
|
|
292
|
+
var FingerprintMatchWindow = class {
|
|
293
|
+
inner;
|
|
294
|
+
constructor(options) {
|
|
295
|
+
if (!cachedBindings) {
|
|
296
|
+
throw new Error("web-audio wasm window is not initialized");
|
|
297
|
+
}
|
|
298
|
+
this.inner = cachedBindings.createWindow(options);
|
|
299
|
+
}
|
|
300
|
+
appendFingerprint(bytes) {
|
|
301
|
+
this.inner.appendFingerprint(bytes);
|
|
302
|
+
}
|
|
303
|
+
hasReadyQuery() {
|
|
304
|
+
return this.inner.hasReadyQuery();
|
|
305
|
+
}
|
|
306
|
+
consumeReadyQuery() {
|
|
307
|
+
return this.inner.takeReadyQuery();
|
|
308
|
+
}
|
|
309
|
+
reset() {
|
|
310
|
+
this.inner.reset();
|
|
311
|
+
}
|
|
312
|
+
destroy() {
|
|
313
|
+
this.inner.destroy?.();
|
|
314
|
+
}
|
|
315
|
+
};
|
|
316
|
+
|
|
317
|
+
// src/activity.ts
|
|
318
|
+
function isRecord2(value) {
|
|
319
|
+
return typeof value === "object" && value !== null;
|
|
320
|
+
}
|
|
321
|
+
function resolveFetch(fetcher) {
|
|
322
|
+
if (fetcher) {
|
|
323
|
+
return fetcher;
|
|
324
|
+
}
|
|
325
|
+
if (typeof globalThis.fetch !== "function") {
|
|
326
|
+
throw new Error("fetch is not available in this environment");
|
|
327
|
+
}
|
|
328
|
+
return globalThis.fetch.bind(globalThis);
|
|
329
|
+
}
|
|
330
|
+
async function resolveToken(token) {
|
|
331
|
+
const resolved = typeof token === "function" ? await token() : token;
|
|
332
|
+
if (typeof resolved !== "string" || resolved.length === 0) {
|
|
333
|
+
throw new Error("Activity reporter token must resolve to a non-empty string");
|
|
334
|
+
}
|
|
335
|
+
return resolved;
|
|
336
|
+
}
|
|
337
|
+
async function resolveRequestPayload(reporter, match) {
|
|
338
|
+
const payload = reporter.mapMatchToRequest ? await reporter.mapMatchToRequest(match) : {
|
|
339
|
+
type: "material",
|
|
340
|
+
material_id: match.name
|
|
341
|
+
};
|
|
342
|
+
if (payload === null) {
|
|
343
|
+
return null;
|
|
344
|
+
}
|
|
345
|
+
if (payload.type !== "material" || typeof payload.material_id !== "string" || payload.material_id.length === 0) {
|
|
346
|
+
throw new Error("Activity reporter payload must include a non-empty material_id");
|
|
347
|
+
}
|
|
348
|
+
return payload;
|
|
349
|
+
}
|
|
350
|
+
function defaultCampaignMapper(payload, match) {
|
|
351
|
+
const campaign = payload.campaign;
|
|
352
|
+
if (!isRecord2(campaign)) {
|
|
353
|
+
return null;
|
|
354
|
+
}
|
|
355
|
+
return {
|
|
356
|
+
match,
|
|
357
|
+
campaign,
|
|
358
|
+
activityId: typeof payload.activity_id === "string" ? payload.activity_id : null,
|
|
359
|
+
raw: payload
|
|
360
|
+
};
|
|
361
|
+
}
|
|
362
|
+
async function reportMatchedMaterialActivity(reporter, match) {
|
|
363
|
+
const payload = await resolveRequestPayload(reporter, match);
|
|
364
|
+
if (!payload) {
|
|
365
|
+
return null;
|
|
366
|
+
}
|
|
367
|
+
const headers = new Headers(
|
|
368
|
+
reporter.headers ? typeof reporter.headers === "function" ? await reporter.headers() : reporter.headers : void 0
|
|
369
|
+
);
|
|
370
|
+
headers.set("authorization", `Bearer ${await resolveToken(reporter.token)}`);
|
|
371
|
+
headers.set("content-type", "application/json");
|
|
372
|
+
const response = await resolveFetch(reporter.fetch)(reporter.endpoint, {
|
|
373
|
+
method: "POST",
|
|
374
|
+
headers,
|
|
375
|
+
body: JSON.stringify(payload)
|
|
376
|
+
});
|
|
377
|
+
if (!response.ok) {
|
|
378
|
+
throw new Error(`Activity reporting failed: HTTP ${response.status}`);
|
|
379
|
+
}
|
|
380
|
+
const responseText = await response.text();
|
|
381
|
+
if (responseText.trim().length === 0) {
|
|
382
|
+
return null;
|
|
383
|
+
}
|
|
384
|
+
let data;
|
|
385
|
+
try {
|
|
386
|
+
data = JSON.parse(responseText);
|
|
387
|
+
} catch {
|
|
388
|
+
throw new Error("Activity reporting failed: invalid JSON response");
|
|
389
|
+
}
|
|
390
|
+
if (!isRecord2(data)) {
|
|
391
|
+
throw new Error("Activity reporting failed: expected an object response");
|
|
392
|
+
}
|
|
393
|
+
if (reporter.mapResponseToCampaign) {
|
|
394
|
+
return reporter.mapResponseToCampaign(data, match);
|
|
395
|
+
}
|
|
396
|
+
return defaultCampaignMapper(data, match);
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
// src/microphone.ts
|
|
400
|
+
var WORKLET_PROCESSOR_NAME = "sori-microphone-capture";
|
|
401
|
+
var audioWorkletModuleLoads = /* @__PURE__ */ new WeakMap();
|
|
402
|
+
function createProcessorModuleSource() {
|
|
403
|
+
return `
|
|
404
|
+
class SoriMicrophoneCaptureProcessor extends AudioWorkletProcessor {
|
|
405
|
+
process(inputs) {
|
|
406
|
+
const input = inputs[0];
|
|
407
|
+
const channel = input && input[0];
|
|
408
|
+
if (channel && channel.length > 0) {
|
|
409
|
+
this.port.postMessage(channel.slice(0));
|
|
410
|
+
}
|
|
411
|
+
return true;
|
|
412
|
+
}
|
|
413
|
+
}
|
|
414
|
+
registerProcessor("${WORKLET_PROCESSOR_NAME}", SoriMicrophoneCaptureProcessor);
|
|
415
|
+
`;
|
|
416
|
+
}
|
|
417
|
+
async function createAudioWorkletGraph({
|
|
418
|
+
audioContext,
|
|
419
|
+
stream
|
|
420
|
+
}) {
|
|
421
|
+
await ensureAudioWorkletModule(audioContext);
|
|
422
|
+
const source = audioContext.createMediaStreamSource(stream);
|
|
423
|
+
const processor = new AudioWorkletNode(audioContext, WORKLET_PROCESSOR_NAME);
|
|
424
|
+
const muteNode = audioContext.createGain();
|
|
425
|
+
muteNode.gain.value = 0;
|
|
426
|
+
return {
|
|
427
|
+
async start(onSamples) {
|
|
428
|
+
processor.port.onmessage = (event) => {
|
|
429
|
+
onSamples(new Float32Array(event.data));
|
|
430
|
+
};
|
|
431
|
+
source.connect(processor);
|
|
432
|
+
processor.connect(muteNode);
|
|
433
|
+
muteNode.connect(audioContext.destination);
|
|
434
|
+
},
|
|
435
|
+
async stop() {
|
|
436
|
+
processor.port.onmessage = null;
|
|
437
|
+
source.disconnect();
|
|
438
|
+
processor.disconnect();
|
|
439
|
+
muteNode.disconnect();
|
|
440
|
+
}
|
|
441
|
+
};
|
|
442
|
+
}
|
|
443
|
+
async function ensureAudioWorkletModule(audioContext) {
|
|
444
|
+
const existingLoad = audioWorkletModuleLoads.get(audioContext);
|
|
445
|
+
if (existingLoad) {
|
|
446
|
+
await existingLoad;
|
|
447
|
+
return;
|
|
448
|
+
}
|
|
449
|
+
const moduleSource = createProcessorModuleSource();
|
|
450
|
+
const moduleUrl = URL.createObjectURL(
|
|
451
|
+
new Blob([moduleSource], { type: "application/javascript" })
|
|
452
|
+
);
|
|
453
|
+
const loadPromise = (async () => {
|
|
454
|
+
try {
|
|
455
|
+
await audioContext.audioWorklet.addModule(moduleUrl);
|
|
456
|
+
} finally {
|
|
457
|
+
URL.revokeObjectURL(moduleUrl);
|
|
458
|
+
}
|
|
459
|
+
})();
|
|
460
|
+
audioWorkletModuleLoads.set(audioContext, loadPromise);
|
|
461
|
+
try {
|
|
462
|
+
await loadPromise;
|
|
463
|
+
} catch (error) {
|
|
464
|
+
if (audioWorkletModuleLoads.get(audioContext) === loadPromise) {
|
|
465
|
+
audioWorkletModuleLoads.delete(audioContext);
|
|
466
|
+
}
|
|
467
|
+
throw error;
|
|
468
|
+
}
|
|
469
|
+
}
|
|
470
|
+
async function createScriptProcessorGraph({
|
|
471
|
+
audioContext,
|
|
472
|
+
stream,
|
|
473
|
+
bufferSize
|
|
474
|
+
}) {
|
|
475
|
+
const source = audioContext.createMediaStreamSource(stream);
|
|
476
|
+
const processor = audioContext.createScriptProcessor(bufferSize, 1, 1);
|
|
477
|
+
const muteNode = audioContext.createGain();
|
|
478
|
+
muteNode.gain.value = 0;
|
|
479
|
+
return {
|
|
480
|
+
async start(onSamples) {
|
|
481
|
+
processor.onaudioprocess = (event) => {
|
|
482
|
+
const channel = event.inputBuffer.getChannelData(0);
|
|
483
|
+
onSamples(new Float32Array(channel));
|
|
484
|
+
};
|
|
485
|
+
source.connect(processor);
|
|
486
|
+
processor.connect(muteNode);
|
|
487
|
+
muteNode.connect(audioContext.destination);
|
|
488
|
+
},
|
|
489
|
+
async stop() {
|
|
490
|
+
processor.onaudioprocess = null;
|
|
491
|
+
source.disconnect();
|
|
492
|
+
processor.disconnect();
|
|
493
|
+
muteNode.disconnect();
|
|
494
|
+
}
|
|
495
|
+
};
|
|
496
|
+
}
|
|
497
|
+
async function requestMicrophoneStream(mediaDevices, mediaConstraints) {
|
|
498
|
+
if (!mediaDevices?.getUserMedia) {
|
|
499
|
+
throw new Error("MediaDevices.getUserMedia is not available");
|
|
500
|
+
}
|
|
501
|
+
return mediaDevices.getUserMedia(
|
|
502
|
+
mediaConstraints ?? {
|
|
503
|
+
audio: {
|
|
504
|
+
channelCount: 1,
|
|
505
|
+
sampleRate: 16e3,
|
|
506
|
+
echoCancellation: false,
|
|
507
|
+
noiseSuppression: false,
|
|
508
|
+
autoGainControl: false
|
|
509
|
+
},
|
|
510
|
+
video: false
|
|
511
|
+
}
|
|
512
|
+
);
|
|
513
|
+
}
|
|
514
|
+
var defaultAudioGraphFactory = async (options) => {
|
|
515
|
+
if ("audioWorklet" in options.audioContext && typeof AudioWorkletNode !== "undefined") {
|
|
516
|
+
return createAudioWorkletGraph(options);
|
|
517
|
+
}
|
|
518
|
+
return createScriptProcessorGraph(options);
|
|
519
|
+
};
|
|
520
|
+
|
|
521
|
+
// src/microphone-matcher.ts
|
|
522
|
+
function normalizeError(error) {
|
|
523
|
+
return error instanceof Error ? error : new Error(String(error));
|
|
524
|
+
}
|
|
525
|
+
function emitError(emitter, phase, error) {
|
|
526
|
+
const normalized = normalizeError(error);
|
|
527
|
+
emitter.emit("error", { phase, error: normalized });
|
|
528
|
+
return normalized;
|
|
529
|
+
}
|
|
530
|
+
function emitAndThrow(emitter, phase, error) {
|
|
531
|
+
throw emitError(emitter, phase, error);
|
|
532
|
+
}
|
|
533
|
+
var MicrophoneMatcher = class {
|
|
534
|
+
events = new TypedEventEmitter();
|
|
535
|
+
matcherSession;
|
|
536
|
+
mediaDevices;
|
|
537
|
+
audioContext;
|
|
538
|
+
stopTracksOnStop;
|
|
539
|
+
suspendContextOnStop;
|
|
540
|
+
audioGraphFactory;
|
|
541
|
+
packSource;
|
|
542
|
+
options;
|
|
543
|
+
extractor = null;
|
|
544
|
+
graphController = null;
|
|
545
|
+
matchWindow = null;
|
|
546
|
+
stream = null;
|
|
547
|
+
running = false;
|
|
548
|
+
destroyed = false;
|
|
549
|
+
loadedPack = false;
|
|
550
|
+
matchInFlight = false;
|
|
551
|
+
pendingMatch = false;
|
|
552
|
+
captureGeneration = 0;
|
|
553
|
+
pcmBuffer = new Float32Array(0);
|
|
554
|
+
resampleBuffer = new Float32Array(0);
|
|
555
|
+
resampleOffset = 0;
|
|
556
|
+
hopSize = 0;
|
|
557
|
+
diagnostics = {
|
|
558
|
+
sampleCallbacks: 0,
|
|
559
|
+
inputSamples: 0,
|
|
560
|
+
normalizedSamples: 0,
|
|
561
|
+
processedChunks: 0,
|
|
562
|
+
fingerprints: 0,
|
|
563
|
+
readyQueries: 0,
|
|
564
|
+
matchRequests: 0
|
|
565
|
+
};
|
|
566
|
+
constructor(options) {
|
|
567
|
+
this.options = options;
|
|
568
|
+
this.matcherSession = new MatcherSession(options.wasm?.matcher);
|
|
569
|
+
this.mediaDevices = getBrowserMediaDevices();
|
|
570
|
+
this.audioContext = createBrowserAudioContext();
|
|
571
|
+
this.stopTracksOnStop = options.stopTracksOnStop ?? true;
|
|
572
|
+
this.suspendContextOnStop = options.suspendContextOnStop ?? true;
|
|
573
|
+
this.audioGraphFactory = defaultAudioGraphFactory;
|
|
574
|
+
this.packSource = options.packSource;
|
|
575
|
+
}
|
|
576
|
+
on(eventName, listener) {
|
|
577
|
+
this.events.on(eventName, listener);
|
|
578
|
+
return this;
|
|
579
|
+
}
|
|
580
|
+
off(eventName, listener) {
|
|
581
|
+
this.events.off(eventName, listener);
|
|
582
|
+
return this;
|
|
583
|
+
}
|
|
584
|
+
once(eventName, listener) {
|
|
585
|
+
this.events.once(eventName, listener);
|
|
586
|
+
return this;
|
|
587
|
+
}
|
|
588
|
+
async prepare() {
|
|
589
|
+
await this.prepareInternal(true);
|
|
590
|
+
}
|
|
591
|
+
async loadPack(source) {
|
|
592
|
+
try {
|
|
593
|
+
await this.prepareInternal(source === void 0);
|
|
594
|
+
const target = source ?? this.packSource;
|
|
595
|
+
if (target === void 0) {
|
|
596
|
+
throw new Error("No pack source provided");
|
|
597
|
+
}
|
|
598
|
+
await this.matcherSession.loadPack(target);
|
|
599
|
+
this.loadedPack = true;
|
|
600
|
+
} catch (error) {
|
|
601
|
+
emitAndThrow(this.events, "loadPack", error);
|
|
602
|
+
}
|
|
603
|
+
}
|
|
604
|
+
async prepareInternal(loadImplicitPack) {
|
|
605
|
+
try {
|
|
606
|
+
this.ensureNotDestroyed();
|
|
607
|
+
await initAfpgen(this.options.wasm?.afpgen);
|
|
608
|
+
await initMatcher2(this.options.wasm?.matcher);
|
|
609
|
+
await initMatchWindowWasm(this.options.wasm?.matchWindow);
|
|
610
|
+
await this.matcherSession.ready();
|
|
611
|
+
if (loadImplicitPack && !this.loadedPack && this.packSource !== void 0) {
|
|
612
|
+
await this.matcherSession.loadPack(this.packSource);
|
|
613
|
+
this.loadedPack = true;
|
|
614
|
+
}
|
|
615
|
+
if (!this.extractor) {
|
|
616
|
+
const extractor = await createExtractor({
|
|
617
|
+
sampleRate: TARGET_SAMPLE_RATE,
|
|
618
|
+
config: DEFAULT_RUNTIME_CONFIG.afpgenConfig
|
|
619
|
+
});
|
|
620
|
+
this.extractor = extractor;
|
|
621
|
+
this.hopSize = DEFAULT_RUNTIME_CONFIG.afpgenConfig.hopSize ?? 1e3;
|
|
622
|
+
this.matchWindow = new FingerprintMatchWindow({
|
|
623
|
+
sampleRate: TARGET_SAMPLE_RATE,
|
|
624
|
+
afpgenConfig: DEFAULT_RUNTIME_CONFIG.afpgenConfig,
|
|
625
|
+
matchWindowMs: DEFAULT_RUNTIME_CONFIG.matchWindowMs,
|
|
626
|
+
matchStrideMs: DEFAULT_RUNTIME_CONFIG.matchStrideMs
|
|
627
|
+
});
|
|
628
|
+
}
|
|
629
|
+
this.events.emit("ready", {
|
|
630
|
+
sampleRate: this.audioContext.sampleRate
|
|
631
|
+
});
|
|
632
|
+
} catch (error) {
|
|
633
|
+
emitAndThrow(this.events, "prepare", error);
|
|
634
|
+
}
|
|
635
|
+
}
|
|
636
|
+
async addEntry(name, fingerprint, afpType = DEFAULT_RUNTIME_CONFIG.initialFingerprintType) {
|
|
637
|
+
try {
|
|
638
|
+
this.ensureNotDestroyed();
|
|
639
|
+
await this.prepare();
|
|
640
|
+
await this.matcherSession.addEntry(name, afpType, fingerprint);
|
|
641
|
+
} catch (error) {
|
|
642
|
+
emitAndThrow(this.events, "addEntry", error);
|
|
643
|
+
}
|
|
644
|
+
}
|
|
645
|
+
async addOrReplace(name, fingerprint, afpType = DEFAULT_RUNTIME_CONFIG.initialFingerprintType) {
|
|
646
|
+
try {
|
|
647
|
+
this.ensureNotDestroyed();
|
|
648
|
+
await this.prepare();
|
|
649
|
+
await this.matcherSession.addOrReplace(name, afpType, fingerprint);
|
|
650
|
+
} catch (error) {
|
|
651
|
+
emitAndThrow(this.events, "addOrReplace", error);
|
|
652
|
+
}
|
|
653
|
+
}
|
|
654
|
+
async clear() {
|
|
655
|
+
try {
|
|
656
|
+
this.ensureNotDestroyed();
|
|
657
|
+
await this.prepare();
|
|
658
|
+
this.matchWindowOrThrow().reset();
|
|
659
|
+
await this.matcherSession.clear();
|
|
660
|
+
} catch (error) {
|
|
661
|
+
emitAndThrow(this.events, "clear", error);
|
|
662
|
+
}
|
|
663
|
+
}
|
|
664
|
+
async start() {
|
|
665
|
+
try {
|
|
666
|
+
this.ensureNotDestroyed();
|
|
667
|
+
if (this.running) {
|
|
668
|
+
return;
|
|
669
|
+
}
|
|
670
|
+
await this.prepare();
|
|
671
|
+
if (this.audioContext.state === "suspended") {
|
|
672
|
+
await this.audioContext.resume();
|
|
673
|
+
}
|
|
674
|
+
const stream = await requestMicrophoneStream(this.mediaDevices, this.options.mediaConstraints);
|
|
675
|
+
this.stream = stream;
|
|
676
|
+
this.events.emit("microphoneready", {
|
|
677
|
+
stream,
|
|
678
|
+
trackCount: stream.getTracks().length
|
|
679
|
+
});
|
|
680
|
+
this.graphController = await this.audioGraphFactory({
|
|
681
|
+
audioContext: this.audioContext,
|
|
682
|
+
stream,
|
|
683
|
+
bufferSize: DEFAULT_RUNTIME_CONFIG.captureBufferSize
|
|
684
|
+
});
|
|
685
|
+
this.running = true;
|
|
686
|
+
this.resetDiagnostics();
|
|
687
|
+
await this.graphController.start((samples) => {
|
|
688
|
+
void this.handleAudioSamples(samples);
|
|
689
|
+
});
|
|
690
|
+
this.events.emit("start", {});
|
|
691
|
+
} catch (error) {
|
|
692
|
+
await this.cleanupAfterStartFailure();
|
|
693
|
+
emitAndThrow(this.events, "start", error);
|
|
694
|
+
}
|
|
695
|
+
}
|
|
696
|
+
async stop() {
|
|
697
|
+
try {
|
|
698
|
+
if (!this.running) {
|
|
699
|
+
return;
|
|
700
|
+
}
|
|
701
|
+
this.running = false;
|
|
702
|
+
await this.graphController?.stop();
|
|
703
|
+
this.graphController = null;
|
|
704
|
+
await this.resetCaptureState();
|
|
705
|
+
if (this.stopTracksOnStop) {
|
|
706
|
+
this.stream?.getTracks().forEach((track) => track.stop());
|
|
707
|
+
}
|
|
708
|
+
this.stream = null;
|
|
709
|
+
if (this.suspendContextOnStop && this.audioContext.state === "running") {
|
|
710
|
+
await this.audioContext.suspend();
|
|
711
|
+
}
|
|
712
|
+
this.events.emit("stop", {});
|
|
713
|
+
} catch (error) {
|
|
714
|
+
emitAndThrow(this.events, "stop", error);
|
|
715
|
+
}
|
|
716
|
+
}
|
|
717
|
+
async destroy() {
|
|
718
|
+
if (this.destroyed) {
|
|
719
|
+
return;
|
|
720
|
+
}
|
|
721
|
+
try {
|
|
722
|
+
await this.stop();
|
|
723
|
+
await this.audioContext.close();
|
|
724
|
+
this.matchWindow?.destroy();
|
|
725
|
+
this.matchWindow = null;
|
|
726
|
+
this.matcherSession.destroy();
|
|
727
|
+
this.destroyed = true;
|
|
728
|
+
this.events.emit("destroy", {});
|
|
729
|
+
this.events.clear();
|
|
730
|
+
} catch (error) {
|
|
731
|
+
emitAndThrow(this.events, "destroy", error);
|
|
732
|
+
}
|
|
733
|
+
}
|
|
734
|
+
getDiagnostics() {
|
|
735
|
+
return { ...this.diagnostics };
|
|
736
|
+
}
|
|
737
|
+
async handleAudioSamples(samples) {
|
|
738
|
+
if (!this.running || !this.extractor) {
|
|
739
|
+
return;
|
|
740
|
+
}
|
|
741
|
+
const captureGeneration = this.captureGeneration;
|
|
742
|
+
this.diagnostics.sampleCallbacks += 1;
|
|
743
|
+
this.diagnostics.inputSamples += samples.length;
|
|
744
|
+
const normalizedSamples = this.normalizeInputSamples(samples);
|
|
745
|
+
if (normalizedSamples.length === 0) {
|
|
746
|
+
return;
|
|
747
|
+
}
|
|
748
|
+
this.diagnostics.normalizedSamples += normalizedSamples.length;
|
|
749
|
+
this.pcmBuffer = appendFloat32(this.pcmBuffer, normalizedSamples);
|
|
750
|
+
while (this.pcmBuffer.length >= this.hopSize) {
|
|
751
|
+
const chunk = this.pcmBuffer.subarray(0, this.hopSize);
|
|
752
|
+
this.pcmBuffer = this.pcmBuffer.slice(this.hopSize);
|
|
753
|
+
this.diagnostics.processedChunks += 1;
|
|
754
|
+
const fingerprint = await this.extractor.processChunk(chunk);
|
|
755
|
+
if (captureGeneration !== this.captureGeneration || !this.running) {
|
|
756
|
+
return;
|
|
757
|
+
}
|
|
758
|
+
if (fingerprint) {
|
|
759
|
+
this.diagnostics.fingerprints += 1;
|
|
760
|
+
this.matchWindowOrThrow().appendFingerprint(fingerprint);
|
|
761
|
+
this.queueMatch(captureGeneration);
|
|
762
|
+
}
|
|
763
|
+
}
|
|
764
|
+
}
|
|
765
|
+
queueMatch(captureGeneration) {
|
|
766
|
+
if (captureGeneration !== this.captureGeneration || !this.running) {
|
|
767
|
+
return;
|
|
768
|
+
}
|
|
769
|
+
if (!this.matchWindowOrThrow().hasReadyQuery()) {
|
|
770
|
+
return;
|
|
771
|
+
}
|
|
772
|
+
if (this.matchInFlight) {
|
|
773
|
+
this.pendingMatch = true;
|
|
774
|
+
return;
|
|
775
|
+
}
|
|
776
|
+
this.matchInFlight = true;
|
|
777
|
+
void this.runMatchLoop(captureGeneration);
|
|
778
|
+
}
|
|
779
|
+
async runMatchLoop(captureGeneration) {
|
|
780
|
+
try {
|
|
781
|
+
do {
|
|
782
|
+
this.pendingMatch = false;
|
|
783
|
+
const query = this.matchWindowOrThrow().consumeReadyQuery();
|
|
784
|
+
if (!query) {
|
|
785
|
+
continue;
|
|
786
|
+
}
|
|
787
|
+
this.diagnostics.readyQueries += 1;
|
|
788
|
+
let distinctBest = null;
|
|
789
|
+
let shouldEmitNoMatch = false;
|
|
790
|
+
const handleMatch = (event) => {
|
|
791
|
+
if (!event.best) {
|
|
792
|
+
return;
|
|
793
|
+
}
|
|
794
|
+
distinctBest = event.best;
|
|
795
|
+
shouldEmitNoMatch = false;
|
|
796
|
+
};
|
|
797
|
+
const handleNoMatch = () => {
|
|
798
|
+
distinctBest = null;
|
|
799
|
+
shouldEmitNoMatch = true;
|
|
800
|
+
};
|
|
801
|
+
this.matcherSession.on("match", handleMatch);
|
|
802
|
+
this.matcherSession.on("nomatch", handleNoMatch);
|
|
803
|
+
try {
|
|
804
|
+
this.diagnostics.matchRequests += 1;
|
|
805
|
+
await this.matcherSession.bestMatch(query, DEFAULT_RUNTIME_CONFIG.matchConfig);
|
|
806
|
+
} finally {
|
|
807
|
+
this.matcherSession.off("match", handleMatch);
|
|
808
|
+
this.matcherSession.off("nomatch", handleNoMatch);
|
|
809
|
+
}
|
|
810
|
+
if (captureGeneration !== this.captureGeneration || !this.running) {
|
|
811
|
+
return;
|
|
812
|
+
}
|
|
813
|
+
if (distinctBest) {
|
|
814
|
+
this.events.emit("match", { best: distinctBest });
|
|
815
|
+
void this.reportCampaign(distinctBest, captureGeneration);
|
|
816
|
+
} else if (shouldEmitNoMatch) {
|
|
817
|
+
this.events.emit("nomatch", {});
|
|
818
|
+
}
|
|
819
|
+
} while (this.pendingMatch || this.matchWindowOrThrow().hasReadyQuery());
|
|
820
|
+
} catch (error) {
|
|
821
|
+
emitError(this.events, "match", error);
|
|
822
|
+
} finally {
|
|
823
|
+
this.matchInFlight = false;
|
|
824
|
+
}
|
|
825
|
+
}
|
|
826
|
+
async resetCaptureState() {
|
|
827
|
+
this.captureGeneration += 1;
|
|
828
|
+
this.pendingMatch = false;
|
|
829
|
+
this.matchInFlight = false;
|
|
830
|
+
this.pcmBuffer = new Float32Array(0);
|
|
831
|
+
this.resampleBuffer = new Float32Array(0);
|
|
832
|
+
this.resampleOffset = 0;
|
|
833
|
+
this.matchWindowOrThrow().reset();
|
|
834
|
+
await this.extractor?.reset();
|
|
835
|
+
}
|
|
836
|
+
normalizeInputSamples(samples) {
|
|
837
|
+
if (this.audioContext.sampleRate === TARGET_SAMPLE_RATE) {
|
|
838
|
+
return samples;
|
|
839
|
+
}
|
|
840
|
+
return this.resampleToTargetRate(samples, this.audioContext.sampleRate, TARGET_SAMPLE_RATE);
|
|
841
|
+
}
|
|
842
|
+
resampleToTargetRate(samples, sourceSampleRate, targetSampleRate) {
|
|
843
|
+
this.resampleBuffer = appendFloat32(this.resampleBuffer, samples);
|
|
844
|
+
const step = sourceSampleRate / targetSampleRate;
|
|
845
|
+
const output = [];
|
|
846
|
+
let offset = this.resampleOffset;
|
|
847
|
+
while (offset + 1 < this.resampleBuffer.length) {
|
|
848
|
+
const leftIndex = Math.floor(offset);
|
|
849
|
+
const fraction = offset - leftIndex;
|
|
850
|
+
const leftSample = this.resampleBuffer[leftIndex] ?? 0;
|
|
851
|
+
const rightSample = this.resampleBuffer[leftIndex + 1] ?? leftSample;
|
|
852
|
+
output.push(leftSample + (rightSample - leftSample) * fraction);
|
|
853
|
+
offset += step;
|
|
854
|
+
}
|
|
855
|
+
const consumed = Math.max(0, Math.floor(offset));
|
|
856
|
+
if (consumed > 0) {
|
|
857
|
+
this.resampleBuffer = this.resampleBuffer.slice(consumed);
|
|
858
|
+
offset -= consumed;
|
|
859
|
+
}
|
|
860
|
+
this.resampleOffset = offset;
|
|
861
|
+
return output.length > 0 ? Float32Array.from(output) : new Float32Array(0);
|
|
862
|
+
}
|
|
863
|
+
async cleanupAfterStartFailure() {
|
|
864
|
+
this.running = false;
|
|
865
|
+
await this.graphController?.stop();
|
|
866
|
+
this.graphController = null;
|
|
867
|
+
await this.resetCaptureState();
|
|
868
|
+
if (this.stopTracksOnStop) {
|
|
869
|
+
this.stream?.getTracks().forEach((track) => track.stop());
|
|
870
|
+
}
|
|
871
|
+
this.stream = null;
|
|
872
|
+
if (this.suspendContextOnStop && this.audioContext.state === "running") {
|
|
873
|
+
await this.audioContext.suspend();
|
|
874
|
+
}
|
|
875
|
+
}
|
|
876
|
+
resetDiagnostics() {
|
|
877
|
+
this.diagnostics = {
|
|
878
|
+
sampleCallbacks: 0,
|
|
879
|
+
inputSamples: 0,
|
|
880
|
+
normalizedSamples: 0,
|
|
881
|
+
processedChunks: 0,
|
|
882
|
+
fingerprints: 0,
|
|
883
|
+
readyQueries: 0,
|
|
884
|
+
matchRequests: 0
|
|
885
|
+
};
|
|
886
|
+
}
|
|
887
|
+
ensureNotDestroyed() {
|
|
888
|
+
if (this.destroyed) {
|
|
889
|
+
throw new Error("MicrophoneMatcher has been destroyed");
|
|
890
|
+
}
|
|
891
|
+
}
|
|
892
|
+
matchWindowOrThrow() {
|
|
893
|
+
if (!this.matchWindow) {
|
|
894
|
+
throw new Error("MicrophoneMatcher is not prepared");
|
|
895
|
+
}
|
|
896
|
+
return this.matchWindow;
|
|
897
|
+
}
|
|
898
|
+
async reportCampaign(match, captureGeneration) {
|
|
899
|
+
const reporter = this.options.activityReporter;
|
|
900
|
+
if (!reporter) {
|
|
901
|
+
return;
|
|
902
|
+
}
|
|
903
|
+
try {
|
|
904
|
+
const campaign = await reportMatchedMaterialActivity(reporter, match);
|
|
905
|
+
if (campaign && captureGeneration === this.captureGeneration && this.running) {
|
|
906
|
+
this.events.emit("campaign", campaign);
|
|
907
|
+
}
|
|
908
|
+
} catch (error) {
|
|
909
|
+
emitError(this.events, "activity", error);
|
|
910
|
+
}
|
|
911
|
+
}
|
|
912
|
+
};
|
|
913
|
+
function appendFloat32(left, right) {
|
|
914
|
+
if (left.length === 0) {
|
|
915
|
+
return new Float32Array(right);
|
|
916
|
+
}
|
|
917
|
+
const merged = new Float32Array(left.length + right.length);
|
|
918
|
+
merged.set(left, 0);
|
|
919
|
+
merged.set(right, left.length);
|
|
920
|
+
return merged;
|
|
921
|
+
}
|
|
922
|
+
async function createMicrophoneMatcher(options = {}) {
|
|
923
|
+
const matcher = new MicrophoneMatcher(options);
|
|
924
|
+
await matcher.prepare();
|
|
925
|
+
return matcher;
|
|
926
|
+
}
|
|
927
|
+
|
|
928
|
+
// src/session-manager.ts
|
|
929
|
+
function resolveStorage2(storage) {
|
|
930
|
+
if (storage) {
|
|
931
|
+
return storage;
|
|
932
|
+
}
|
|
933
|
+
if (typeof globalThis.localStorage === "undefined") {
|
|
934
|
+
throw new Error("localStorage is not available in this environment");
|
|
935
|
+
}
|
|
936
|
+
return globalThis.localStorage;
|
|
937
|
+
}
|
|
938
|
+
function defaultGenerateSessionId() {
|
|
939
|
+
if (typeof globalThis.crypto !== "undefined" && typeof globalThis.crypto.randomUUID === "function") {
|
|
940
|
+
return globalThis.crypto.randomUUID();
|
|
941
|
+
}
|
|
942
|
+
return `session-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 10)}`;
|
|
943
|
+
}
|
|
944
|
+
function createLocalStorageSessionManager(options) {
|
|
945
|
+
const storage = resolveStorage2(options.storage);
|
|
946
|
+
const generateSessionId = options.generateSessionId ?? defaultGenerateSessionId;
|
|
947
|
+
return {
|
|
948
|
+
getSessionId() {
|
|
949
|
+
const current = storage.getItem(options.key);
|
|
950
|
+
if (typeof current === "string" && current.length > 0) {
|
|
951
|
+
return current;
|
|
952
|
+
}
|
|
953
|
+
const next = generateSessionId();
|
|
954
|
+
storage.setItem(options.key, next);
|
|
955
|
+
return next;
|
|
956
|
+
},
|
|
957
|
+
clear() {
|
|
958
|
+
storage.removeItem(options.key);
|
|
959
|
+
}
|
|
960
|
+
};
|
|
961
|
+
}
|
|
962
|
+
|
|
963
|
+
// src/audio-recognizer.ts
|
|
964
|
+
var DEFAULT_SORI_API_ENDPOINT = "https://console.soriapi.com/api";
|
|
965
|
+
var FORWARDED_EVENTS = [
|
|
966
|
+
"ready",
|
|
967
|
+
"microphoneready",
|
|
968
|
+
"start",
|
|
969
|
+
"match",
|
|
970
|
+
"campaign",
|
|
971
|
+
"nomatch",
|
|
972
|
+
"stop",
|
|
973
|
+
"destroy",
|
|
974
|
+
"error"
|
|
975
|
+
];
|
|
976
|
+
function ensureFetch(fetcher) {
|
|
977
|
+
if (fetcher) {
|
|
978
|
+
return fetcher;
|
|
979
|
+
}
|
|
980
|
+
if (typeof globalThis.fetch !== "function") {
|
|
981
|
+
throw new Error("fetch is not available in this environment");
|
|
982
|
+
}
|
|
983
|
+
return globalThis.fetch.bind(globalThis);
|
|
984
|
+
}
|
|
985
|
+
function ensureNonEmptyString(name, value) {
|
|
986
|
+
if (typeof value !== "string" || value.length === 0) {
|
|
987
|
+
throw new Error(`AudioRecognizer ${name} is missing`);
|
|
988
|
+
}
|
|
989
|
+
return value;
|
|
990
|
+
}
|
|
991
|
+
function normalizeEndpointPrefix(endpoint) {
|
|
992
|
+
const value = String(endpoint).replace(/\/+$/, "");
|
|
993
|
+
return value;
|
|
994
|
+
}
|
|
995
|
+
function joinEndpoint(prefix, path) {
|
|
996
|
+
return `${normalizeEndpointPrefix(prefix)}${path.startsWith("/") ? path : `/${path}`}`;
|
|
997
|
+
}
|
|
998
|
+
function createEphemeralKeyResolver(endpoint, fetcher, requestInit) {
|
|
999
|
+
return async () => {
|
|
1000
|
+
const resolvedRequestInit = typeof requestInit === "function" ? await requestInit() : requestInit;
|
|
1001
|
+
const response = await ensureFetch(fetcher)(endpoint, {
|
|
1002
|
+
method: resolvedRequestInit?.method ?? "POST",
|
|
1003
|
+
...resolvedRequestInit
|
|
1004
|
+
});
|
|
1005
|
+
if (!response.ok) {
|
|
1006
|
+
throw new Error(`Ephemeral key request failed: HTTP ${response.status}`);
|
|
1007
|
+
}
|
|
1008
|
+
let payload;
|
|
1009
|
+
try {
|
|
1010
|
+
payload = await response.json();
|
|
1011
|
+
} catch {
|
|
1012
|
+
throw new Error("Ephemeral key request failed: invalid JSON response");
|
|
1013
|
+
}
|
|
1014
|
+
return ensureNonEmptyString("ephemeral key", payload.ephemeralKey ?? payload.ephemeral_key);
|
|
1015
|
+
};
|
|
1016
|
+
}
|
|
1017
|
+
function resolveRecognizerOptions(options) {
|
|
1018
|
+
const apiEndpoint = options.apiEndpoint ?? DEFAULT_SORI_API_ENDPOINT;
|
|
1019
|
+
const sharedFetch = options.fetch;
|
|
1020
|
+
const legacyAuth = options.auth;
|
|
1021
|
+
const activityReportingDisabled = options.activityReporter === false;
|
|
1022
|
+
const customActivityReporter = activityReportingDisabled ? void 0 : options.activityReporter;
|
|
1023
|
+
const appId = legacyAuth?.appId ?? options.appId;
|
|
1024
|
+
const authEndpoint = legacyAuth?.endpoint ?? options.authEndpoint ?? joinEndpoint(apiEndpoint, "/auth");
|
|
1025
|
+
const activityEndpoint = activityReportingDisabled ? void 0 : customActivityReporter?.endpoint ?? options.activityEndpoint ?? joinEndpoint(apiEndpoint, "/activity/");
|
|
1026
|
+
const ephemeralKey = legacyAuth?.ephemeralKey ?? options.ephemeralKey ?? options.ephemeraKey ?? (legacyAuth?.ephemeralKeyEndpoint ?? options.ephemeralKeyEndpoint ? createEphemeralKeyResolver(
|
|
1027
|
+
legacyAuth?.ephemeralKeyEndpoint ?? options.ephemeralKeyEndpoint,
|
|
1028
|
+
legacyAuth?.ephemeralKeyFetch ?? options.ephemeralKeyFetch ?? legacyAuth?.fetch ?? sharedFetch,
|
|
1029
|
+
legacyAuth?.ephemeralKeyRequestInit ?? options.ephemeralKeyRequestInit
|
|
1030
|
+
) : void 0);
|
|
1031
|
+
const activityReporter = activityEndpoint === void 0 ? void 0 : {
|
|
1032
|
+
...customActivityReporter,
|
|
1033
|
+
endpoint: activityEndpoint,
|
|
1034
|
+
fetch: customActivityReporter?.fetch ?? sharedFetch
|
|
1035
|
+
};
|
|
1036
|
+
return {
|
|
1037
|
+
...options,
|
|
1038
|
+
auth: {
|
|
1039
|
+
endpoint: authEndpoint,
|
|
1040
|
+
appId: ensureNonEmptyString("appId", appId),
|
|
1041
|
+
fetch: legacyAuth?.fetch ?? sharedFetch,
|
|
1042
|
+
ephemeralKey
|
|
1043
|
+
},
|
|
1044
|
+
activityReporter
|
|
1045
|
+
};
|
|
1046
|
+
}
|
|
1047
|
+
function resolvePackStore(options) {
|
|
1048
|
+
if (options.packStore) {
|
|
1049
|
+
return options.packStore;
|
|
1050
|
+
}
|
|
1051
|
+
return createLocalStorageAudioPackStore({
|
|
1052
|
+
key: options.packStorageKey ?? `sorisdk:web-audio:pack:${options.auth.appId}`
|
|
1053
|
+
});
|
|
1054
|
+
}
|
|
1055
|
+
function resolveSessionManager(options) {
|
|
1056
|
+
if (options.sessionManager) {
|
|
1057
|
+
return options.sessionManager;
|
|
1058
|
+
}
|
|
1059
|
+
return createLocalStorageSessionManager({
|
|
1060
|
+
key: options.sessionStorageKey ?? `sorisdk:web-audio:session:${options.auth.appId}`
|
|
1061
|
+
});
|
|
1062
|
+
}
|
|
1063
|
+
async function resolveEphemeralKey(auth) {
|
|
1064
|
+
if (typeof auth.ephemeralKey === "string") {
|
|
1065
|
+
return ensureNonEmptyString("ephemeral key", auth.ephemeralKey);
|
|
1066
|
+
}
|
|
1067
|
+
if (typeof auth.ephemeralKey === "function") {
|
|
1068
|
+
return ensureNonEmptyString("ephemeral key", await auth.ephemeralKey());
|
|
1069
|
+
}
|
|
1070
|
+
throw new Error(
|
|
1071
|
+
"AudioRecognizer auth requires either `ephemeralKey` or `ephemeralKeyEndpoint`"
|
|
1072
|
+
);
|
|
1073
|
+
}
|
|
1074
|
+
var AudioRecognizer = class {
|
|
1075
|
+
events = new TypedEventEmitter();
|
|
1076
|
+
options;
|
|
1077
|
+
packStore;
|
|
1078
|
+
sessionManager;
|
|
1079
|
+
matcher = null;
|
|
1080
|
+
matcherPromise = null;
|
|
1081
|
+
activityToken = null;
|
|
1082
|
+
running = false;
|
|
1083
|
+
destroyed = false;
|
|
1084
|
+
constructor(options) {
|
|
1085
|
+
this.options = resolveRecognizerOptions(options);
|
|
1086
|
+
this.packStore = resolvePackStore(this.options);
|
|
1087
|
+
this.sessionManager = resolveSessionManager(this.options);
|
|
1088
|
+
}
|
|
1089
|
+
on(eventName, listener) {
|
|
1090
|
+
this.events.on(eventName, listener);
|
|
1091
|
+
return this;
|
|
1092
|
+
}
|
|
1093
|
+
off(eventName, listener) {
|
|
1094
|
+
this.events.off(eventName, listener);
|
|
1095
|
+
return this;
|
|
1096
|
+
}
|
|
1097
|
+
once(eventName, listener) {
|
|
1098
|
+
this.events.once(eventName, listener);
|
|
1099
|
+
return this;
|
|
1100
|
+
}
|
|
1101
|
+
async start() {
|
|
1102
|
+
this.ensureNotDestroyed();
|
|
1103
|
+
if (this.running) {
|
|
1104
|
+
return;
|
|
1105
|
+
}
|
|
1106
|
+
const matcher = await this.ensureMatcher();
|
|
1107
|
+
const sessionId = this.sessionManager.getSessionId();
|
|
1108
|
+
const ephemeralKey = await resolveEphemeralKey(this.options.auth);
|
|
1109
|
+
const authResult = await authenticateAndLoadAudioPack({
|
|
1110
|
+
loader: matcher,
|
|
1111
|
+
auth: {
|
|
1112
|
+
endpoint: this.options.auth.endpoint,
|
|
1113
|
+
appId: this.options.auth.appId,
|
|
1114
|
+
ephemeralKey,
|
|
1115
|
+
sessionId,
|
|
1116
|
+
fetch: this.options.auth.fetch
|
|
1117
|
+
},
|
|
1118
|
+
initialVersion: this.options.initialVersion ?? DEFAULT_BROWSER_AUDIOPACK_VERSION,
|
|
1119
|
+
matcherInit: this.options.wasm?.matcher,
|
|
1120
|
+
store: this.packStore
|
|
1121
|
+
});
|
|
1122
|
+
this.setAuthResult(authResult);
|
|
1123
|
+
await matcher.start();
|
|
1124
|
+
}
|
|
1125
|
+
async stop() {
|
|
1126
|
+
const matcher = this.matcher;
|
|
1127
|
+
if (!matcher) {
|
|
1128
|
+
this.running = false;
|
|
1129
|
+
return;
|
|
1130
|
+
}
|
|
1131
|
+
await matcher.stop();
|
|
1132
|
+
}
|
|
1133
|
+
async destroy() {
|
|
1134
|
+
if (this.destroyed) {
|
|
1135
|
+
return;
|
|
1136
|
+
}
|
|
1137
|
+
this.destroyed = true;
|
|
1138
|
+
const matcher = this.matcher ?? (this.matcherPromise ? await this.matcherPromise : null);
|
|
1139
|
+
await matcher?.destroy();
|
|
1140
|
+
this.matcher = null;
|
|
1141
|
+
this.matcherPromise = null;
|
|
1142
|
+
this.activityToken = null;
|
|
1143
|
+
this.running = false;
|
|
1144
|
+
this.events.clear();
|
|
1145
|
+
}
|
|
1146
|
+
getDiagnostics() {
|
|
1147
|
+
return this.matcher?.getDiagnostics() ?? null;
|
|
1148
|
+
}
|
|
1149
|
+
async ensureMatcher() {
|
|
1150
|
+
if (this.matcher) {
|
|
1151
|
+
return this.matcher;
|
|
1152
|
+
}
|
|
1153
|
+
if (!this.matcherPromise) {
|
|
1154
|
+
this.matcherPromise = (async () => {
|
|
1155
|
+
try {
|
|
1156
|
+
const matcher = await createMicrophoneMatcher({
|
|
1157
|
+
...this.options,
|
|
1158
|
+
activityReporter: this.options.activityReporter ? {
|
|
1159
|
+
...this.options.activityReporter,
|
|
1160
|
+
token: async () => {
|
|
1161
|
+
if (!this.activityToken) {
|
|
1162
|
+
throw new Error("AudioRecognizer auth token is not available yet");
|
|
1163
|
+
}
|
|
1164
|
+
return this.activityToken;
|
|
1165
|
+
}
|
|
1166
|
+
} : void 0
|
|
1167
|
+
});
|
|
1168
|
+
this.bindMatcherEvents(matcher);
|
|
1169
|
+
this.matcher = matcher;
|
|
1170
|
+
return matcher;
|
|
1171
|
+
} catch (error) {
|
|
1172
|
+
this.matcherPromise = null;
|
|
1173
|
+
throw error;
|
|
1174
|
+
}
|
|
1175
|
+
})();
|
|
1176
|
+
}
|
|
1177
|
+
return this.matcherPromise;
|
|
1178
|
+
}
|
|
1179
|
+
bindMatcherEvents(matcher) {
|
|
1180
|
+
for (const eventName of FORWARDED_EVENTS) {
|
|
1181
|
+
matcher.on(eventName, (payload) => {
|
|
1182
|
+
if (eventName === "start") {
|
|
1183
|
+
this.running = true;
|
|
1184
|
+
} else if (eventName === "stop" || eventName === "destroy") {
|
|
1185
|
+
this.running = false;
|
|
1186
|
+
}
|
|
1187
|
+
this.events.emit(
|
|
1188
|
+
eventName,
|
|
1189
|
+
payload
|
|
1190
|
+
);
|
|
1191
|
+
});
|
|
1192
|
+
}
|
|
1193
|
+
}
|
|
1194
|
+
ensureNotDestroyed() {
|
|
1195
|
+
if (this.destroyed) {
|
|
1196
|
+
throw new Error("AudioRecognizer has been destroyed");
|
|
1197
|
+
}
|
|
1198
|
+
}
|
|
1199
|
+
setAuthResult(result) {
|
|
1200
|
+
this.activityToken = result.token;
|
|
1201
|
+
}
|
|
1202
|
+
};
|
|
1203
|
+
|
|
1204
|
+
// src/index.ts
|
|
1205
|
+
import { AudioFingerprintType as AudioFingerprintType3 } from "@sorisdk/matcher";
|
|
1206
|
+
export {
|
|
1207
|
+
AudioFingerprintType3 as AudioFingerprintType,
|
|
1208
|
+
AudioRecognizer,
|
|
1209
|
+
DEFAULT_BROWSER_AUDIOPACK_VERSION,
|
|
1210
|
+
DEFAULT_SORI_API_ENDPOINT,
|
|
1211
|
+
MicrophoneMatcher,
|
|
1212
|
+
authenticateAndLoadAudioPack,
|
|
1213
|
+
createLocalStorageAudioPackStore,
|
|
1214
|
+
createLocalStorageSessionManager,
|
|
1215
|
+
createMicrophoneMatcher,
|
|
1216
|
+
requestMicrophoneStream
|
|
1217
|
+
};
|