@sorisdk/web-audio 0.6.3 → 0.6.5
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 +117 -4
- package/dist/index.d.ts +32 -1
- package/dist/index.js +570 -44
- package/package.json +4 -4
package/README.md
CHANGED
|
@@ -22,7 +22,7 @@ configuration, or Node-based asset server is required:
|
|
|
22
22
|
```html
|
|
23
23
|
<script type="module">
|
|
24
24
|
import { AudioRecognizer } from
|
|
25
|
-
"https://cdn.iplateia.com/web/sorisdk/v0.6.
|
|
25
|
+
"https://cdn.iplateia.com/web/sorisdk/v0.6.5/sori-web-audio.mjs";
|
|
26
26
|
|
|
27
27
|
const recognizer = new AudioRecognizer({
|
|
28
28
|
appId: "YOUR_APP_ID",
|
|
@@ -49,7 +49,7 @@ configuration, or Node-based asset server is required:
|
|
|
49
49
|
```
|
|
50
50
|
|
|
51
51
|
Static URL imports are valid inside `<script type="module">`. Dynamic
|
|
52
|
-
`await import("https://cdn.iplateia.com/web/sorisdk/v0.6.
|
|
52
|
+
`await import("https://cdn.iplateia.com/web/sorisdk/v0.6.5/sori-web-audio.mjs")`
|
|
53
53
|
is an optional alternative when the SDK should be loaded conditionally.
|
|
54
54
|
|
|
55
55
|
Pin an exact version in production. Do not construct a mutable `latest` URL.
|
|
@@ -93,7 +93,7 @@ An import map can give the standalone URL the npm package name:
|
|
|
93
93
|
<script type="importmap">
|
|
94
94
|
{
|
|
95
95
|
"imports": {
|
|
96
|
-
"@sorisdk/web-audio": "https://cdn.iplateia.com/web/sorisdk/v0.6.
|
|
96
|
+
"@sorisdk/web-audio": "https://cdn.iplateia.com/web/sorisdk/v0.6.5/sori-web-audio.mjs"
|
|
97
97
|
}
|
|
98
98
|
}
|
|
99
99
|
</script>
|
|
@@ -142,7 +142,7 @@ const recognizer = new AudioRecognizer({
|
|
|
142
142
|
});
|
|
143
143
|
|
|
144
144
|
recognizer.on("campaign", (event) => {
|
|
145
|
-
console.log(event.campaign);
|
|
145
|
+
console.log(event.activityId, event.campaign);
|
|
146
146
|
});
|
|
147
147
|
|
|
148
148
|
recognizer.on("error", ({ error }) => {
|
|
@@ -159,6 +159,119 @@ await recognizer.stop();
|
|
|
159
159
|
await recognizer.destroy();
|
|
160
160
|
```
|
|
161
161
|
|
|
162
|
+
## Activity refinement
|
|
163
|
+
|
|
164
|
+
The default `AudioRecognizer` activity reporter represents one fingerprint
|
|
165
|
+
recognition and its later audio-marker enrichment as one server activity:
|
|
166
|
+
|
|
167
|
+
1. A marker-free match with an active marker-scan request is created with
|
|
168
|
+
`POST /api/activity/` and top-level `refinement_expected: true`.
|
|
169
|
+
2. The returned `activity_id` is retained for 30 seconds.
|
|
170
|
+
3. If the same recognition gains `audioMarker.code`, the SDK sends
|
|
171
|
+
`PUT /api/activity/{activity_id}` with the same material and
|
|
172
|
+
`trait.marker`.
|
|
173
|
+
4. The refined `campaign` event keeps the same non-empty `activityId`, so an
|
|
174
|
+
application can replace the earlier campaign row deterministically.
|
|
175
|
+
|
|
176
|
+
A marker present on the first match is included in the POST and does not cause
|
|
177
|
+
a PUT or include `refinement_expected`. Recognition without an active marker
|
|
178
|
+
scan, plus legacy/custom POST-only reporters, also omit the opt-in flag.
|
|
179
|
+
Marker-state `audiomarker` events remain independently observable; they do not
|
|
180
|
+
mean that server activity refinement completed.
|
|
181
|
+
|
|
182
|
+
Network and 5xx PUT failures are retried at most twice within the original
|
|
183
|
+
30-second deadline. Server responses 404, 409, and 410 are terminal. An
|
|
184
|
+
expired or failed refinement is dropped and never becomes a second POST.
|
|
185
|
+
Stopping, clearing, destroying, or replacing the authenticated capture clears
|
|
186
|
+
pending refinement identity.
|
|
187
|
+
|
|
188
|
+
Custom `activityReporter` configurations remain POST-only by default. Enable
|
|
189
|
+
refinement explicitly and, if needed, provide operation-specific transport or
|
|
190
|
+
mapping hooks:
|
|
191
|
+
|
|
192
|
+
```ts
|
|
193
|
+
const recognizer = new AudioRecognizer({
|
|
194
|
+
appId: "YOUR_APP_ID",
|
|
195
|
+
ephemeralKey: fetchEphemeralKeyFromYourServer,
|
|
196
|
+
activityReporter: {
|
|
197
|
+
endpoint: "https://example.com/activity/",
|
|
198
|
+
refinement: {
|
|
199
|
+
endpoint: (activityId) =>
|
|
200
|
+
`https://example.com/activity/${encodeURIComponent(activityId)}`,
|
|
201
|
+
maxAttempts: 2,
|
|
202
|
+
shouldRetry: ({ status }) => status === null || status >= 500
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
});
|
|
206
|
+
```
|
|
207
|
+
|
|
208
|
+
Existing `mapMatchToRequest` and `mapResponseToCampaign` hooks continue to
|
|
209
|
+
handle POST. Additive refinement hooks can override the PUT payload, response
|
|
210
|
+
mapping, endpoint, headers, fetcher, and retry policy. The stable terminal
|
|
211
|
+
statuses 404, 409, and 410 are never retried even if a custom policy returns
|
|
212
|
+
`true`.
|
|
213
|
+
|
|
214
|
+
## Session identifiers
|
|
215
|
+
|
|
216
|
+
`AudioRecognizer` creates one pseudonymous session identifier per application
|
|
217
|
+
and stores it in `localStorage` under
|
|
218
|
+
`sorisdk:web-audio:session:<appId>`. The value persists until the origin's
|
|
219
|
+
storage is cleared or the session manager's `clear()` method is called. An
|
|
220
|
+
upgrade does not rotate an existing non-empty value, including identifiers
|
|
221
|
+
created by older SDK versions.
|
|
222
|
+
|
|
223
|
+
New identifiers use `crypto.randomUUID()`. Browsers without `randomUUID()` use
|
|
224
|
+
`crypto.getRandomValues()` to create an RFC 4122 UUID v4. If neither secure API
|
|
225
|
+
is available, session creation fails instead of falling back to predictable
|
|
226
|
+
randomness. A host for such an environment must provide a cryptographically
|
|
227
|
+
secure generator explicitly:
|
|
228
|
+
|
|
229
|
+
```ts
|
|
230
|
+
import {
|
|
231
|
+
AudioRecognizer,
|
|
232
|
+
createLocalStorageSessionManager
|
|
233
|
+
} from "@sorisdk/web-audio";
|
|
234
|
+
|
|
235
|
+
const sessionManager = createLocalStorageSessionManager({
|
|
236
|
+
key: "sorisdk:web-audio:session:YOUR_APP_ID",
|
|
237
|
+
generateSessionId: () => secureSessionIdFromYourRuntime()
|
|
238
|
+
});
|
|
239
|
+
|
|
240
|
+
const recognizer = new AudioRecognizer({
|
|
241
|
+
appId: "YOUR_APP_ID",
|
|
242
|
+
ephemeralKey: fetchEphemeralKeyFromYourServer,
|
|
243
|
+
sessionManager
|
|
244
|
+
});
|
|
245
|
+
|
|
246
|
+
// Stop recognition before intentionally rotating the identifier.
|
|
247
|
+
await recognizer.destroy();
|
|
248
|
+
await sessionManager.clear?.();
|
|
249
|
+
```
|
|
250
|
+
|
|
251
|
+
Treat the identifier as persistent pseudonymous data: do not include personal
|
|
252
|
+
information in a custom value, and do not log or expose it unnecessarily.
|
|
253
|
+
Clearing it breaks device and activity continuity; the next authentication
|
|
254
|
+
creates a new server-side device identity.
|
|
255
|
+
|
|
256
|
+
### SORI service contract
|
|
257
|
+
|
|
258
|
+
The current SORI service uses the identifier at these boundaries:
|
|
259
|
+
|
|
260
|
+
| Boundary | Use of `sessionId` | Security contract |
|
|
261
|
+
| --- | --- | --- |
|
|
262
|
+
| Authentication request | Debounces repeated requests and upserts an account-scoped device record | A valid application ID plus secret or ephemeral key is still required |
|
|
263
|
+
| Session token | Stored as the signed device claim | Possession of the raw identifier does not create or validate a token |
|
|
264
|
+
| Recognition activity | Attributes impressions, clicks, campaign links, and campaign webhooks to a device | Authorization comes from the signed token, not the identifier |
|
|
265
|
+
| Monitoring | Binds stored health and transition data to the token's device claim | A submitted device ID must match the signed claim |
|
|
266
|
+
| Authentication webhook | Populates the webhook `device_id` | The value is correlation data, not a webhook credential |
|
|
267
|
+
| Quota and throttling | Selects the authentication debounce bucket only | It is not a standalone billing or usage quota key |
|
|
268
|
+
| AudioPack caching | Not used as a cache partition | The browser cache remains application-scoped |
|
|
269
|
+
| Replay protection | No use | The stable identifier provides neither freshness nor replay protection |
|
|
270
|
+
|
|
271
|
+
Authorization, quota enforcement, cache isolation, and replay controls must
|
|
272
|
+
remain bound to authenticated server-side state rather than possession or
|
|
273
|
+
unpredictability of `sessionId`.
|
|
274
|
+
|
|
162
275
|
## Advanced wasm loading overrides
|
|
163
276
|
|
|
164
277
|
If you need to pin explicit generated modules, use the nested `wasm` options:
|
package/dist/index.d.ts
CHANGED
|
@@ -62,6 +62,11 @@ interface AudioMarkerEvent {
|
|
|
62
62
|
interface MaterialActivityRequestPayload {
|
|
63
63
|
type: "material";
|
|
64
64
|
material_id: string;
|
|
65
|
+
refinement_expected?: true;
|
|
66
|
+
trait?: {
|
|
67
|
+
marker?: string;
|
|
68
|
+
[key: string]: unknown;
|
|
69
|
+
};
|
|
65
70
|
metadata?: unknown;
|
|
66
71
|
}
|
|
67
72
|
interface MicrophoneMatcherCampaignEvent {
|
|
@@ -70,6 +75,27 @@ interface MicrophoneMatcherCampaignEvent {
|
|
|
70
75
|
activityId: string | null;
|
|
71
76
|
raw: Record<string, unknown>;
|
|
72
77
|
}
|
|
78
|
+
interface ActivityRefinementRequestContext {
|
|
79
|
+
operation: "refine";
|
|
80
|
+
activityId: string;
|
|
81
|
+
attempt: number;
|
|
82
|
+
deadline: number;
|
|
83
|
+
}
|
|
84
|
+
interface ActivityRefinementFailureContext extends ActivityRefinementRequestContext {
|
|
85
|
+
error: Error;
|
|
86
|
+
status: number | null;
|
|
87
|
+
detail: string | null;
|
|
88
|
+
}
|
|
89
|
+
interface MicrophoneMatcherActivityRefinementOptions {
|
|
90
|
+
endpoint?: string | URL | ((activityId: string) => string | URL | Promise<string | URL>);
|
|
91
|
+
fetch?: typeof fetch;
|
|
92
|
+
headers?: HeadersInit | (() => HeadersInit | Promise<HeadersInit>);
|
|
93
|
+
mapMatchToRequest?: (match: DetectedMatch, context: ActivityRefinementRequestContext) => MaterialActivityRequestPayload | null | Promise<MaterialActivityRequestPayload | null>;
|
|
94
|
+
mapResponseToCampaign?: (response: Record<string, unknown>, match: DetectedMatch, context: ActivityRefinementRequestContext) => MicrophoneMatcherCampaignEvent | null | Promise<MicrophoneMatcherCampaignEvent | null>;
|
|
95
|
+
shouldRetry?: (context: ActivityRefinementFailureContext) => boolean | Promise<boolean>;
|
|
96
|
+
maxAttempts?: number;
|
|
97
|
+
now?: () => number;
|
|
98
|
+
}
|
|
73
99
|
interface MicrophoneMatcherActivityReporterOptions {
|
|
74
100
|
endpoint: string | URL;
|
|
75
101
|
token: string | (() => string | Promise<string>);
|
|
@@ -77,6 +103,7 @@ interface MicrophoneMatcherActivityReporterOptions {
|
|
|
77
103
|
headers?: HeadersInit | (() => HeadersInit | Promise<HeadersInit>);
|
|
78
104
|
mapMatchToRequest?: (match: DetectedMatch) => MaterialActivityRequestPayload | null | Promise<MaterialActivityRequestPayload | null>;
|
|
79
105
|
mapResponseToCampaign?: (response: Record<string, unknown>, match: DetectedMatch) => MicrophoneMatcherCampaignEvent | null | Promise<MicrophoneMatcherCampaignEvent | null>;
|
|
106
|
+
refinement?: false | MicrophoneMatcherActivityRefinementOptions;
|
|
80
107
|
}
|
|
81
108
|
interface CreateMicrophoneMatcherWasmOptions {
|
|
82
109
|
afpgen?: InitAfpgenOptions;
|
|
@@ -182,6 +209,8 @@ declare class AudioRecognizer {
|
|
|
182
209
|
private setAuthResult;
|
|
183
210
|
}
|
|
184
211
|
|
|
212
|
+
declare const ACTIVITY_REFINEMENT_WINDOW_MS = 30000;
|
|
213
|
+
|
|
185
214
|
declare class MicrophoneMatcher {
|
|
186
215
|
private readonly events;
|
|
187
216
|
private readonly matcherSession;
|
|
@@ -193,6 +222,7 @@ declare class MicrophoneMatcher {
|
|
|
193
222
|
private readonly captureSampleRate;
|
|
194
223
|
private readonly audioGraphFactory;
|
|
195
224
|
private readonly packSource;
|
|
225
|
+
private readonly activityRefinement;
|
|
196
226
|
private readonly options;
|
|
197
227
|
private extractor;
|
|
198
228
|
private graphController;
|
|
@@ -211,6 +241,7 @@ declare class MicrophoneMatcher {
|
|
|
211
241
|
private latestAudioMarker;
|
|
212
242
|
private latestAudioMarkerDetection;
|
|
213
243
|
private audioMarkerRequestId;
|
|
244
|
+
private activityRecognitionId;
|
|
214
245
|
private hopSize;
|
|
215
246
|
private diagnostics;
|
|
216
247
|
constructor(options: CreateMicrophoneMatcherOptions);
|
|
@@ -260,4 +291,4 @@ declare function requestMicrophoneStream(mediaDevices: MediaDevices, mediaConstr
|
|
|
260
291
|
preferredSampleRate?: number;
|
|
261
292
|
}): Promise<MediaStream>;
|
|
262
293
|
|
|
263
|
-
export { type AudioMarkerEvent, AudioRecognizer, type AudioRecognizerAuthOptions, type AudioRecognizerOptions, type AuthenticateAndLoadAudioPackOptions, type AuthenticateAndLoadAudioPackResult, type BrowserAudioPackLoader, type BrowserAudioPackState, type BrowserAudioPackStateStore, type BrowserSessionManager, type CreateLocalStorageAudioPackStoreOptions, type CreateLocalStorageSessionManagerOptions, type CreateMicrophoneMatcherOptions, type CreateMicrophoneMatcherWasmOptions, DEFAULT_BROWSER_AUDIOPACK_VERSION, DEFAULT_SORI_API_ENDPOINT, type DetectedMatch, type MaterialActivityRequestPayload, MicrophoneMatcher, type MicrophoneMatcherActivityReporterOptions, type MicrophoneMatcherCampaignEvent, type MicrophoneMatcherEventMap, type MicrophoneMatcherEventName, type MicrophoneMatcherListener, authenticateAndLoadAudioPack, createLocalStorageAudioPackStore, createLocalStorageSessionManager, createMicrophoneMatcher, requestMicrophoneStream };
|
|
294
|
+
export { ACTIVITY_REFINEMENT_WINDOW_MS, type ActivityRefinementFailureContext, type ActivityRefinementRequestContext, type AudioMarkerEvent, AudioRecognizer, type AudioRecognizerAuthOptions, type AudioRecognizerOptions, type AuthenticateAndLoadAudioPackOptions, type AuthenticateAndLoadAudioPackResult, type BrowserAudioPackLoader, type BrowserAudioPackState, type BrowserAudioPackStateStore, type BrowserSessionManager, type CreateLocalStorageAudioPackStoreOptions, type CreateLocalStorageSessionManagerOptions, type CreateMicrophoneMatcherOptions, type CreateMicrophoneMatcherWasmOptions, DEFAULT_BROWSER_AUDIOPACK_VERSION, DEFAULT_SORI_API_ENDPOINT, type DetectedMatch, type MaterialActivityRequestPayload, MicrophoneMatcher, type MicrophoneMatcherActivityRefinementOptions, type MicrophoneMatcherActivityReporterOptions, type MicrophoneMatcherCampaignEvent, type MicrophoneMatcherEventMap, type MicrophoneMatcherEventName, type MicrophoneMatcherListener, authenticateAndLoadAudioPack, createLocalStorageAudioPackStore, createLocalStorageSessionManager, createMicrophoneMatcher, requestMicrophoneStream };
|
package/dist/index.js
CHANGED
|
@@ -201,6 +201,7 @@ var WrappedFingerprintMatchWindow = class {
|
|
|
201
201
|
constructor(inner) {
|
|
202
202
|
this.inner = inner;
|
|
203
203
|
}
|
|
204
|
+
inner;
|
|
204
205
|
appendFingerprint(bytes) {
|
|
205
206
|
const fn = getCallable(this.inner, ["appendFingerprint", "append_fingerprint"]);
|
|
206
207
|
if (!fn) {
|
|
@@ -313,6 +314,22 @@ var FingerprintMatchWindow = class {
|
|
|
313
314
|
};
|
|
314
315
|
|
|
315
316
|
// src/activity.ts
|
|
317
|
+
var ActivityReportingError = class extends Error {
|
|
318
|
+
status;
|
|
319
|
+
detail;
|
|
320
|
+
constructor(message, status = null, detail = null) {
|
|
321
|
+
super(message);
|
|
322
|
+
this.name = "ActivityReportingError";
|
|
323
|
+
this.status = status;
|
|
324
|
+
this.detail = detail;
|
|
325
|
+
}
|
|
326
|
+
};
|
|
327
|
+
var ActivityReportingCancelledError = class extends Error {
|
|
328
|
+
constructor() {
|
|
329
|
+
super("Activity reporting was cancelled before transport");
|
|
330
|
+
this.name = "ActivityReportingCancelledError";
|
|
331
|
+
}
|
|
332
|
+
};
|
|
316
333
|
function isRecord2(value) {
|
|
317
334
|
return typeof value === "object" && value !== null;
|
|
318
335
|
}
|
|
@@ -332,11 +349,15 @@ async function resolveToken(token) {
|
|
|
332
349
|
}
|
|
333
350
|
return resolved;
|
|
334
351
|
}
|
|
335
|
-
|
|
336
|
-
const
|
|
352
|
+
function defaultRequestPayload(match) {
|
|
353
|
+
const marker = match.audioMarker?.code;
|
|
354
|
+
return {
|
|
337
355
|
type: "material",
|
|
338
|
-
material_id: match.name
|
|
356
|
+
material_id: match.name,
|
|
357
|
+
...typeof marker === "string" && marker.length > 0 ? { trait: { marker } } : {}
|
|
339
358
|
};
|
|
359
|
+
}
|
|
360
|
+
function validateRequestPayload(payload) {
|
|
340
361
|
if (payload === null) {
|
|
341
362
|
return null;
|
|
342
363
|
}
|
|
@@ -345,6 +366,30 @@ async function resolveRequestPayload(reporter, match) {
|
|
|
345
366
|
}
|
|
346
367
|
return payload;
|
|
347
368
|
}
|
|
369
|
+
async function resolveCreateRequestPayload(reporter, match) {
|
|
370
|
+
const payload = validateRequestPayload(
|
|
371
|
+
reporter.mapMatchToRequest ? await reporter.mapMatchToRequest(match) : defaultRequestPayload(match)
|
|
372
|
+
);
|
|
373
|
+
if (!payload) {
|
|
374
|
+
return null;
|
|
375
|
+
}
|
|
376
|
+
const { refinement_expected: _ignored, ...requestPayload } = payload;
|
|
377
|
+
return requestPayload;
|
|
378
|
+
}
|
|
379
|
+
async function resolveRefineRequestPayload(reporter, match, context) {
|
|
380
|
+
const refinement = reporter.refinement;
|
|
381
|
+
if (!refinement) {
|
|
382
|
+
return null;
|
|
383
|
+
}
|
|
384
|
+
const payload = validateRequestPayload(
|
|
385
|
+
refinement.mapMatchToRequest ? await refinement.mapMatchToRequest(match, context) : reporter.mapMatchToRequest ? await reporter.mapMatchToRequest(match) : defaultRequestPayload(match)
|
|
386
|
+
);
|
|
387
|
+
if (!payload) {
|
|
388
|
+
return null;
|
|
389
|
+
}
|
|
390
|
+
const { refinement_expected: _ignored, ...requestPayload } = payload;
|
|
391
|
+
return requestPayload;
|
|
392
|
+
}
|
|
348
393
|
function defaultCampaignMapper(payload, match) {
|
|
349
394
|
const campaign = payload.campaign;
|
|
350
395
|
if (!isRecord2(campaign)) {
|
|
@@ -357,42 +402,462 @@ function defaultCampaignMapper(payload, match) {
|
|
|
357
402
|
raw: payload
|
|
358
403
|
};
|
|
359
404
|
}
|
|
360
|
-
async function
|
|
361
|
-
const
|
|
362
|
-
|
|
363
|
-
|
|
405
|
+
async function resolveHeaders(reporter, refinement = false) {
|
|
406
|
+
const refinementHeaders = reporter.refinement && reporter.refinement.headers;
|
|
407
|
+
const baseHeaders = reporter.headers ? typeof reporter.headers === "function" ? await reporter.headers() : reporter.headers : void 0;
|
|
408
|
+
const headers = new Headers(baseHeaders);
|
|
409
|
+
if (refinement && refinementHeaders !== void 0) {
|
|
410
|
+
const overrides = new Headers(
|
|
411
|
+
typeof refinementHeaders === "function" ? await refinementHeaders() : refinementHeaders
|
|
412
|
+
);
|
|
413
|
+
overrides.forEach((value, name) => headers.set(name, value));
|
|
364
414
|
}
|
|
365
|
-
const headers = new Headers(
|
|
366
|
-
reporter.headers ? typeof reporter.headers === "function" ? await reporter.headers() : reporter.headers : void 0
|
|
367
|
-
);
|
|
368
415
|
headers.set("authorization", `Bearer ${await resolveToken(reporter.token)}`);
|
|
369
416
|
headers.set("content-type", "application/json");
|
|
417
|
+
return headers;
|
|
418
|
+
}
|
|
419
|
+
async function parseActivityResponse(response, operation) {
|
|
420
|
+
const responseText = await response.text();
|
|
421
|
+
let data = null;
|
|
422
|
+
if (responseText.trim().length > 0) {
|
|
423
|
+
try {
|
|
424
|
+
data = JSON.parse(responseText);
|
|
425
|
+
} catch {
|
|
426
|
+
throw new ActivityReportingError(
|
|
427
|
+
`Activity ${operation} failed: invalid JSON response`,
|
|
428
|
+
response.status
|
|
429
|
+
);
|
|
430
|
+
}
|
|
431
|
+
}
|
|
432
|
+
const succeeded = operation === "refine" ? response.status === 200 : response.ok;
|
|
433
|
+
if (!succeeded) {
|
|
434
|
+
const detail = isRecord2(data) && typeof data.detail === "string" ? data.detail : null;
|
|
435
|
+
throw new ActivityReportingError(
|
|
436
|
+
`Activity ${operation} failed: HTTP ${response.status}${detail ? ` (${detail})` : ""}`,
|
|
437
|
+
response.status,
|
|
438
|
+
detail
|
|
439
|
+
);
|
|
440
|
+
}
|
|
441
|
+
if (data === null) {
|
|
442
|
+
return null;
|
|
443
|
+
}
|
|
444
|
+
if (!isRecord2(data)) {
|
|
445
|
+
throw new ActivityReportingError(
|
|
446
|
+
`Activity ${operation} failed: expected an object response`,
|
|
447
|
+
response.status
|
|
448
|
+
);
|
|
449
|
+
}
|
|
450
|
+
return data;
|
|
451
|
+
}
|
|
452
|
+
function responseActivityId(data, campaign) {
|
|
453
|
+
if (data && typeof data.activity_id === "string" && data.activity_id.length > 0) {
|
|
454
|
+
return data.activity_id;
|
|
455
|
+
}
|
|
456
|
+
return campaign?.activityId && campaign.activityId.length > 0 ? campaign.activityId : null;
|
|
457
|
+
}
|
|
458
|
+
async function mapCreateResponse(reporter, data, match, refinementExpected) {
|
|
459
|
+
if (!data) {
|
|
460
|
+
return { activityId: null, campaign: null, refinementExpected };
|
|
461
|
+
}
|
|
462
|
+
const campaign = reporter.mapResponseToCampaign ? await reporter.mapResponseToCampaign(data, match) : defaultCampaignMapper(data, match);
|
|
463
|
+
return { activityId: responseActivityId(data, campaign), campaign, refinementExpected };
|
|
464
|
+
}
|
|
465
|
+
async function resolveRefinementEndpoint(reporter, activityId) {
|
|
466
|
+
const configured = reporter.refinement && reporter.refinement.endpoint;
|
|
467
|
+
if (typeof configured === "function") {
|
|
468
|
+
return configured(activityId);
|
|
469
|
+
}
|
|
470
|
+
const prefix = String(configured ?? reporter.endpoint);
|
|
471
|
+
return `${prefix.endsWith("/") ? prefix : `${prefix}/`}${encodeURIComponent(activityId)}`;
|
|
472
|
+
}
|
|
473
|
+
async function createMatchedMaterialActivity(reporter, match, refinementExpected = false) {
|
|
474
|
+
const payload = await resolveCreateRequestPayload(reporter, match);
|
|
475
|
+
if (!payload) {
|
|
476
|
+
return { activityId: null, campaign: null, refinementExpected: false };
|
|
477
|
+
}
|
|
478
|
+
const headers = await resolveHeaders(reporter);
|
|
479
|
+
const actualMarker = match.audioMarker?.code;
|
|
480
|
+
const payloadMarker = payload.trait?.marker;
|
|
481
|
+
const hasMarker = typeof actualMarker === "string" && actualMarker.length > 0 || typeof payloadMarker === "string" && payloadMarker.length > 0;
|
|
482
|
+
const lifecycleOpen = typeof refinementExpected === "function" ? refinementExpected() : refinementExpected;
|
|
483
|
+
const requestRefinementExpected = Boolean(reporter.refinement && lifecycleOpen && !hasMarker);
|
|
484
|
+
const requestPayload = requestRefinementExpected ? { ...payload, refinement_expected: true } : payload;
|
|
370
485
|
const response = await resolveFetch(reporter.fetch)(reporter.endpoint, {
|
|
371
486
|
method: "POST",
|
|
372
487
|
headers,
|
|
488
|
+
body: JSON.stringify(requestPayload)
|
|
489
|
+
});
|
|
490
|
+
return mapCreateResponse(
|
|
491
|
+
reporter,
|
|
492
|
+
await parseActivityResponse(response, "create"),
|
|
493
|
+
match,
|
|
494
|
+
requestRefinementExpected
|
|
495
|
+
);
|
|
496
|
+
}
|
|
497
|
+
async function refineMatchedMaterialActivity(reporter, match, context, isActive = () => true) {
|
|
498
|
+
const refinement = reporter.refinement;
|
|
499
|
+
if (!refinement) {
|
|
500
|
+
return { activityId: null, campaign: null };
|
|
501
|
+
}
|
|
502
|
+
const payload = await resolveRefineRequestPayload(reporter, match, context);
|
|
503
|
+
if (!payload) {
|
|
504
|
+
return { activityId: null, campaign: null };
|
|
505
|
+
}
|
|
506
|
+
const endpoint = await resolveRefinementEndpoint(reporter, context.activityId);
|
|
507
|
+
const headers = await resolveHeaders(reporter, true);
|
|
508
|
+
if (!isActive()) {
|
|
509
|
+
throw new ActivityReportingCancelledError();
|
|
510
|
+
}
|
|
511
|
+
const response = await resolveFetch(refinement.fetch ?? reporter.fetch)(endpoint, {
|
|
512
|
+
method: "PUT",
|
|
513
|
+
headers,
|
|
373
514
|
body: JSON.stringify(payload)
|
|
374
515
|
});
|
|
375
|
-
|
|
376
|
-
|
|
516
|
+
const data = await parseActivityResponse(response, "refine");
|
|
517
|
+
if (!data) {
|
|
518
|
+
throw new ActivityReportingError("Activity refine failed: empty response", response.status);
|
|
519
|
+
}
|
|
520
|
+
const mappedCampaign = refinement.mapResponseToCampaign ? await refinement.mapResponseToCampaign(data, match, context) : reporter.mapResponseToCampaign ? await reporter.mapResponseToCampaign(data, match) : defaultCampaignMapper(data, match);
|
|
521
|
+
const activityId = responseActivityId(data, mappedCampaign);
|
|
522
|
+
if (activityId !== context.activityId) {
|
|
523
|
+
throw new ActivityReportingError(
|
|
524
|
+
"Activity refine failed: response activity_id does not match the requested activity",
|
|
525
|
+
response.status
|
|
526
|
+
);
|
|
377
527
|
}
|
|
378
|
-
const
|
|
379
|
-
|
|
380
|
-
|
|
528
|
+
const campaign = mappedCampaign ? { ...mappedCampaign, activityId: context.activityId } : null;
|
|
529
|
+
return { activityId, campaign };
|
|
530
|
+
}
|
|
531
|
+
|
|
532
|
+
// src/activity-refinement.ts
|
|
533
|
+
var ACTIVITY_REFINEMENT_WINDOW_MS = 3e4;
|
|
534
|
+
var DEFAULT_MAX_REFINEMENT_ATTEMPTS = 2;
|
|
535
|
+
var MAX_REFINEMENT_ATTEMPTS = 10;
|
|
536
|
+
function normalizeError(error) {
|
|
537
|
+
return error instanceof Error ? error : new Error(String(error));
|
|
538
|
+
}
|
|
539
|
+
function statusAndDetail(error) {
|
|
540
|
+
return error instanceof ActivityReportingError ? { status: error.status, detail: error.detail } : { status: null, detail: null };
|
|
541
|
+
}
|
|
542
|
+
function defaultShouldRetry(context) {
|
|
543
|
+
return context.status === null || context.status >= 500 && context.status <= 599;
|
|
544
|
+
}
|
|
545
|
+
function isStableTerminalStatus(status) {
|
|
546
|
+
return status === 404 || status === 409 || status === 410;
|
|
547
|
+
}
|
|
548
|
+
var ActivityRefinementCoordinator = class {
|
|
549
|
+
reporter;
|
|
550
|
+
callbacks;
|
|
551
|
+
lifecycles = /* @__PURE__ */ new Map();
|
|
552
|
+
requestLifecycles = /* @__PURE__ */ new Map();
|
|
553
|
+
pendingMarkers = /* @__PURE__ */ new Map();
|
|
554
|
+
registeredRequests = /* @__PURE__ */ new Set();
|
|
555
|
+
settledRequests = /* @__PURE__ */ new Set();
|
|
556
|
+
activeLifecycle = null;
|
|
557
|
+
generation = 0;
|
|
558
|
+
constructor(reporter, callbacks) {
|
|
559
|
+
this.reporter = reporter;
|
|
560
|
+
this.callbacks = callbacks;
|
|
561
|
+
}
|
|
562
|
+
registerRequest(requestId) {
|
|
563
|
+
if (this.reporter.refinement) {
|
|
564
|
+
this.registeredRequests.add(requestId);
|
|
565
|
+
}
|
|
381
566
|
}
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
567
|
+
report(recognitionId, match, captureGeneration, requestId = null) {
|
|
568
|
+
const coordinatorGeneration = this.generation;
|
|
569
|
+
if (this.activeLifecycle && this.activeLifecycle.recognitionId !== recognitionId) {
|
|
570
|
+
this.deleteLifecycle(this.activeLifecycle);
|
|
571
|
+
}
|
|
572
|
+
const hasMarker = typeof match.audioMarker?.code === "string" && match.audioMarker.code.length > 0;
|
|
573
|
+
const refinement = this.reporter.refinement;
|
|
574
|
+
const requestCanRefine = requestId !== null && !this.settledRequests.has(requestId);
|
|
575
|
+
const pendingMarker = requestId === null ? void 0 : this.pendingMarkers.get(requestId);
|
|
576
|
+
if (requestId !== null) {
|
|
577
|
+
this.pendingMarkers.delete(requestId);
|
|
578
|
+
this.registeredRequests.delete(requestId);
|
|
579
|
+
this.settledRequests.delete(requestId);
|
|
580
|
+
}
|
|
581
|
+
const refinementExpected = Boolean(refinement && !hasMarker && requestCanRefine);
|
|
582
|
+
let lifecycle = null;
|
|
583
|
+
if (refinementExpected && refinement) {
|
|
584
|
+
const now = refinement.now?.() ?? Date.now();
|
|
585
|
+
lifecycle = {
|
|
586
|
+
recognitionId,
|
|
587
|
+
captureGeneration,
|
|
588
|
+
match,
|
|
589
|
+
deadline: now + ACTIVITY_REFINEMENT_WINDOW_MS,
|
|
590
|
+
activityId: null,
|
|
591
|
+
markerMatch: null,
|
|
592
|
+
markerCode: null,
|
|
593
|
+
timer: null,
|
|
594
|
+
createSettled: false,
|
|
595
|
+
refinementStarted: false
|
|
596
|
+
};
|
|
597
|
+
const createdLifecycle = lifecycle;
|
|
598
|
+
lifecycle.timer = setTimeout(() => {
|
|
599
|
+
this.deleteLifecycle(createdLifecycle);
|
|
600
|
+
}, ACTIVITY_REFINEMENT_WINDOW_MS);
|
|
601
|
+
this.lifecycles.set(recognitionId, lifecycle);
|
|
602
|
+
if (requestId !== null) {
|
|
603
|
+
this.requestLifecycles.set(requestId, lifecycle);
|
|
604
|
+
}
|
|
605
|
+
this.activeLifecycle = lifecycle;
|
|
606
|
+
if (pendingMarker) {
|
|
607
|
+
this.refine(requestId ?? recognitionId, pendingMarker, captureGeneration);
|
|
608
|
+
}
|
|
609
|
+
}
|
|
610
|
+
void createMatchedMaterialActivity(
|
|
611
|
+
this.reporter,
|
|
612
|
+
match,
|
|
613
|
+
() => Boolean(lifecycle && this.isRefinementActive(lifecycle))
|
|
614
|
+
).then(
|
|
615
|
+
(result) => {
|
|
616
|
+
if (coordinatorGeneration === this.generation && result.campaign && this.callbacks.isCurrentCapture(captureGeneration)) {
|
|
617
|
+
this.callbacks.emitCampaign(result.campaign);
|
|
618
|
+
}
|
|
619
|
+
if (coordinatorGeneration !== this.generation || !lifecycle || this.lifecycles.get(recognitionId) !== lifecycle) {
|
|
620
|
+
return;
|
|
621
|
+
}
|
|
622
|
+
if (!result.refinementExpected) {
|
|
623
|
+
this.deleteLifecycle(lifecycle);
|
|
624
|
+
return;
|
|
625
|
+
}
|
|
626
|
+
lifecycle.createSettled = true;
|
|
627
|
+
lifecycle.activityId = result.activityId;
|
|
628
|
+
if (lifecycle.markerMatch) {
|
|
629
|
+
if (lifecycle.activityId) {
|
|
630
|
+
this.startRefinement(lifecycle);
|
|
631
|
+
} else {
|
|
632
|
+
this.failMissingActivityId(lifecycle);
|
|
633
|
+
}
|
|
634
|
+
}
|
|
635
|
+
},
|
|
636
|
+
(error) => {
|
|
637
|
+
if (lifecycle) {
|
|
638
|
+
this.deleteLifecycle(lifecycle);
|
|
639
|
+
}
|
|
640
|
+
if (coordinatorGeneration === this.generation && this.callbacks.isCurrentCapture(captureGeneration)) {
|
|
641
|
+
this.callbacks.emitError("activity", error);
|
|
642
|
+
}
|
|
643
|
+
}
|
|
644
|
+
);
|
|
387
645
|
}
|
|
388
|
-
|
|
389
|
-
|
|
646
|
+
associate(requestId, match, captureGeneration) {
|
|
647
|
+
const lifecycle = this.activeLifecycle;
|
|
648
|
+
this.registeredRequests.delete(requestId);
|
|
649
|
+
if (!lifecycle || !match || lifecycle.captureGeneration !== captureGeneration || lifecycle.match.name !== match.name || lifecycle.match.afpType !== match.afpType || !this.isActive(lifecycle)) {
|
|
650
|
+
return false;
|
|
651
|
+
}
|
|
652
|
+
this.requestLifecycles.set(requestId, lifecycle);
|
|
653
|
+
const pendingMarker = this.pendingMarkers.get(requestId);
|
|
654
|
+
if (pendingMarker) {
|
|
655
|
+
this.pendingMarkers.delete(requestId);
|
|
656
|
+
this.refine(requestId, pendingMarker, captureGeneration);
|
|
657
|
+
}
|
|
658
|
+
return true;
|
|
390
659
|
}
|
|
391
|
-
|
|
392
|
-
|
|
660
|
+
discardRequest(requestId) {
|
|
661
|
+
this.registeredRequests.delete(requestId);
|
|
662
|
+
this.pendingMarkers.delete(requestId);
|
|
663
|
+
this.requestLifecycles.delete(requestId);
|
|
664
|
+
this.settledRequests.delete(requestId);
|
|
665
|
+
}
|
|
666
|
+
settleRequest(requestId) {
|
|
667
|
+
const awaitingAssociation = this.registeredRequests.delete(requestId);
|
|
668
|
+
this.pendingMarkers.delete(requestId);
|
|
669
|
+
const lifecycle = this.requestLifecycles.get(requestId);
|
|
670
|
+
if (lifecycle) {
|
|
671
|
+
this.deleteLifecycle(lifecycle);
|
|
672
|
+
return;
|
|
673
|
+
}
|
|
674
|
+
if (awaitingAssociation) {
|
|
675
|
+
this.settledRequests.add(requestId);
|
|
676
|
+
}
|
|
393
677
|
}
|
|
394
|
-
|
|
395
|
-
|
|
678
|
+
clearActive(captureGeneration) {
|
|
679
|
+
if (this.activeLifecycle?.captureGeneration === captureGeneration) {
|
|
680
|
+
this.deleteLifecycle(this.activeLifecycle);
|
|
681
|
+
}
|
|
682
|
+
}
|
|
683
|
+
refine(recognitionId, marker, captureGeneration) {
|
|
684
|
+
if (!this.reporter.refinement) {
|
|
685
|
+
return;
|
|
686
|
+
}
|
|
687
|
+
const lifecycle = this.requestLifecycles.get(recognitionId);
|
|
688
|
+
const markerCode = marker.code;
|
|
689
|
+
if (typeof markerCode !== "string" || markerCode.length === 0) {
|
|
690
|
+
this.settleRequest(recognitionId);
|
|
691
|
+
return;
|
|
692
|
+
}
|
|
693
|
+
if (!lifecycle || lifecycle.captureGeneration !== captureGeneration || lifecycle.markerCode !== null || !this.callbacks.isCurrentCapture(captureGeneration)) {
|
|
694
|
+
if (!lifecycle && this.registeredRequests.has(recognitionId) && typeof markerCode === "string" && markerCode.length > 0 && this.callbacks.isCurrentCapture(captureGeneration)) {
|
|
695
|
+
this.pendingMarkers.set(recognitionId, marker);
|
|
696
|
+
}
|
|
697
|
+
return;
|
|
698
|
+
}
|
|
699
|
+
const now = this.reporter.refinement?.now?.() ?? Date.now();
|
|
700
|
+
if (now > lifecycle.deadline) {
|
|
701
|
+
this.deleteLifecycle(lifecycle);
|
|
702
|
+
return;
|
|
703
|
+
}
|
|
704
|
+
lifecycle.markerCode = markerCode;
|
|
705
|
+
lifecycle.markerMatch = { ...lifecycle.match, audioMarker: marker };
|
|
706
|
+
if (lifecycle.activityId) {
|
|
707
|
+
this.startRefinement(lifecycle);
|
|
708
|
+
} else if (lifecycle.createSettled) {
|
|
709
|
+
this.failMissingActivityId(lifecycle);
|
|
710
|
+
}
|
|
711
|
+
}
|
|
712
|
+
reset() {
|
|
713
|
+
this.generation += 1;
|
|
714
|
+
for (const lifecycle of this.lifecycles.values()) {
|
|
715
|
+
if (lifecycle.timer) {
|
|
716
|
+
clearTimeout(lifecycle.timer);
|
|
717
|
+
}
|
|
718
|
+
}
|
|
719
|
+
this.lifecycles.clear();
|
|
720
|
+
this.requestLifecycles.clear();
|
|
721
|
+
this.pendingMarkers.clear();
|
|
722
|
+
this.registeredRequests.clear();
|
|
723
|
+
this.settledRequests.clear();
|
|
724
|
+
this.activeLifecycle = null;
|
|
725
|
+
}
|
|
726
|
+
startRefinement(lifecycle) {
|
|
727
|
+
if (lifecycle.refinementStarted || !lifecycle.activityId || !lifecycle.markerMatch) {
|
|
728
|
+
return;
|
|
729
|
+
}
|
|
730
|
+
lifecycle.refinementStarted = true;
|
|
731
|
+
void this.runRefinement(lifecycle);
|
|
732
|
+
}
|
|
733
|
+
failMissingActivityId(lifecycle) {
|
|
734
|
+
if (!this.isRefinementActive(lifecycle)) {
|
|
735
|
+
return;
|
|
736
|
+
}
|
|
737
|
+
this.deleteLifecycle(lifecycle);
|
|
738
|
+
this.callbacks.emitError(
|
|
739
|
+
"activity-refinement",
|
|
740
|
+
new Error("Activity refinement requires a non-empty activity_id from the create response")
|
|
741
|
+
);
|
|
742
|
+
}
|
|
743
|
+
async runRefinement(lifecycle) {
|
|
744
|
+
const refinement = this.reporter.refinement;
|
|
745
|
+
if (!refinement || !lifecycle.activityId || !lifecycle.markerMatch) {
|
|
746
|
+
this.deleteLifecycle(lifecycle);
|
|
747
|
+
return;
|
|
748
|
+
}
|
|
749
|
+
const maxAttempts = Math.min(
|
|
750
|
+
MAX_REFINEMENT_ATTEMPTS,
|
|
751
|
+
Math.max(
|
|
752
|
+
1,
|
|
753
|
+
Number.isSafeInteger(refinement.maxAttempts) ? refinement.maxAttempts ?? DEFAULT_MAX_REFINEMENT_ATTEMPTS : DEFAULT_MAX_REFINEMENT_ATTEMPTS
|
|
754
|
+
)
|
|
755
|
+
);
|
|
756
|
+
for (let attempt = 1; attempt <= maxAttempts; attempt += 1) {
|
|
757
|
+
if (!this.isActive(lifecycle)) {
|
|
758
|
+
return;
|
|
759
|
+
}
|
|
760
|
+
const now = refinement.now?.() ?? Date.now();
|
|
761
|
+
if (now > lifecycle.deadline) {
|
|
762
|
+
this.deleteLifecycle(lifecycle);
|
|
763
|
+
return;
|
|
764
|
+
}
|
|
765
|
+
try {
|
|
766
|
+
const result = await refineMatchedMaterialActivity(
|
|
767
|
+
this.reporter,
|
|
768
|
+
lifecycle.markerMatch,
|
|
769
|
+
{
|
|
770
|
+
operation: "refine",
|
|
771
|
+
activityId: lifecycle.activityId,
|
|
772
|
+
attempt,
|
|
773
|
+
deadline: lifecycle.deadline
|
|
774
|
+
},
|
|
775
|
+
() => this.isRefinementActive(lifecycle)
|
|
776
|
+
);
|
|
777
|
+
if (!this.isRefinementActive(lifecycle)) {
|
|
778
|
+
return;
|
|
779
|
+
}
|
|
780
|
+
this.deleteLifecycle(lifecycle);
|
|
781
|
+
if (result.campaign) {
|
|
782
|
+
this.callbacks.emitCampaign(result.campaign);
|
|
783
|
+
}
|
|
784
|
+
return;
|
|
785
|
+
} catch (caught) {
|
|
786
|
+
if (caught instanceof ActivityReportingCancelledError) {
|
|
787
|
+
return;
|
|
788
|
+
}
|
|
789
|
+
if (!this.isRefinementActive(lifecycle)) {
|
|
790
|
+
return;
|
|
791
|
+
}
|
|
792
|
+
const error = normalizeError(caught);
|
|
793
|
+
const { status, detail } = statusAndDetail(error);
|
|
794
|
+
const failureContext = {
|
|
795
|
+
operation: "refine",
|
|
796
|
+
activityId: lifecycle.activityId,
|
|
797
|
+
attempt,
|
|
798
|
+
deadline: lifecycle.deadline,
|
|
799
|
+
error,
|
|
800
|
+
status,
|
|
801
|
+
detail
|
|
802
|
+
};
|
|
803
|
+
let retry = false;
|
|
804
|
+
try {
|
|
805
|
+
retry = !isStableTerminalStatus(status) && attempt < maxAttempts && (refinement.now?.() ?? Date.now()) <= lifecycle.deadline && (refinement.shouldRetry ? await refinement.shouldRetry(failureContext) : defaultShouldRetry(failureContext));
|
|
806
|
+
} catch (policyError) {
|
|
807
|
+
if (!this.isRefinementActive(lifecycle)) {
|
|
808
|
+
return;
|
|
809
|
+
}
|
|
810
|
+
this.deleteLifecycle(lifecycle);
|
|
811
|
+
this.callbacks.emitError("activity-refinement", policyError);
|
|
812
|
+
return;
|
|
813
|
+
}
|
|
814
|
+
if (!this.isRefinementActive(lifecycle)) {
|
|
815
|
+
return;
|
|
816
|
+
}
|
|
817
|
+
if (retry) {
|
|
818
|
+
continue;
|
|
819
|
+
}
|
|
820
|
+
this.deleteLifecycle(lifecycle);
|
|
821
|
+
this.callbacks.emitError("activity-refinement", error);
|
|
822
|
+
return;
|
|
823
|
+
}
|
|
824
|
+
}
|
|
825
|
+
}
|
|
826
|
+
isActive(lifecycle) {
|
|
827
|
+
return this.lifecycles.get(lifecycle.recognitionId) === lifecycle && this.callbacks.isCurrentCapture(lifecycle.captureGeneration);
|
|
828
|
+
}
|
|
829
|
+
isRefinementActive(lifecycle) {
|
|
830
|
+
if (!this.isActive(lifecycle)) {
|
|
831
|
+
return false;
|
|
832
|
+
}
|
|
833
|
+
const refinement = this.reporter.refinement;
|
|
834
|
+
const now = refinement && refinement.now ? refinement.now() : Date.now();
|
|
835
|
+
if (now > lifecycle.deadline) {
|
|
836
|
+
this.deleteLifecycle(lifecycle);
|
|
837
|
+
return false;
|
|
838
|
+
}
|
|
839
|
+
return true;
|
|
840
|
+
}
|
|
841
|
+
deleteLifecycle(lifecycle) {
|
|
842
|
+
if (lifecycle.timer) {
|
|
843
|
+
clearTimeout(lifecycle.timer);
|
|
844
|
+
lifecycle.timer = null;
|
|
845
|
+
}
|
|
846
|
+
if (this.lifecycles.get(lifecycle.recognitionId) === lifecycle) {
|
|
847
|
+
this.lifecycles.delete(lifecycle.recognitionId);
|
|
848
|
+
}
|
|
849
|
+
for (const [requestId, requestLifecycle] of this.requestLifecycles) {
|
|
850
|
+
if (requestLifecycle === lifecycle) {
|
|
851
|
+
this.requestLifecycles.delete(requestId);
|
|
852
|
+
this.pendingMarkers.delete(requestId);
|
|
853
|
+
this.registeredRequests.delete(requestId);
|
|
854
|
+
}
|
|
855
|
+
}
|
|
856
|
+
if (this.activeLifecycle === lifecycle) {
|
|
857
|
+
this.activeLifecycle = null;
|
|
858
|
+
}
|
|
859
|
+
}
|
|
860
|
+
};
|
|
396
861
|
|
|
397
862
|
// src/microphone.ts
|
|
398
863
|
var WORKLET_PROCESSOR_NAME = "sori-microphone-capture";
|
|
@@ -517,11 +982,11 @@ var defaultAudioGraphFactory = async (options) => {
|
|
|
517
982
|
};
|
|
518
983
|
|
|
519
984
|
// src/microphone-matcher.ts
|
|
520
|
-
function
|
|
985
|
+
function normalizeError2(error) {
|
|
521
986
|
return error instanceof Error ? error : new Error(String(error));
|
|
522
987
|
}
|
|
523
988
|
function emitError(emitter, phase, error) {
|
|
524
|
-
const normalized =
|
|
989
|
+
const normalized = normalizeError2(error);
|
|
525
990
|
emitter.emit("error", { phase, error: normalized });
|
|
526
991
|
return normalized;
|
|
527
992
|
}
|
|
@@ -539,6 +1004,7 @@ var MicrophoneMatcher = class {
|
|
|
539
1004
|
captureSampleRate;
|
|
540
1005
|
audioGraphFactory;
|
|
541
1006
|
packSource;
|
|
1007
|
+
activityRefinement;
|
|
542
1008
|
options;
|
|
543
1009
|
extractor = null;
|
|
544
1010
|
graphController = null;
|
|
@@ -557,6 +1023,7 @@ var MicrophoneMatcher = class {
|
|
|
557
1023
|
latestAudioMarker = null;
|
|
558
1024
|
latestAudioMarkerDetection = null;
|
|
559
1025
|
audioMarkerRequestId = 0;
|
|
1026
|
+
activityRecognitionId = 0;
|
|
560
1027
|
hopSize = 0;
|
|
561
1028
|
diagnostics = {
|
|
562
1029
|
sampleCallbacks: 0,
|
|
@@ -578,6 +1045,11 @@ var MicrophoneMatcher = class {
|
|
|
578
1045
|
this.suspendContextOnStop = options.suspendContextOnStop ?? true;
|
|
579
1046
|
this.audioGraphFactory = defaultAudioGraphFactory;
|
|
580
1047
|
this.packSource = options.packSource;
|
|
1048
|
+
this.activityRefinement = options.activityReporter ? new ActivityRefinementCoordinator(options.activityReporter, {
|
|
1049
|
+
isCurrentCapture: (captureGeneration) => captureGeneration === this.captureGeneration && this.running,
|
|
1050
|
+
emitCampaign: (campaign) => this.events.emit("campaign", campaign),
|
|
1051
|
+
emitError: (phase, error) => emitError(this.events, phase, error)
|
|
1052
|
+
}) : null;
|
|
581
1053
|
}
|
|
582
1054
|
on(eventName, listener) {
|
|
583
1055
|
this.events.on(eventName, listener);
|
|
@@ -661,6 +1133,7 @@ var MicrophoneMatcher = class {
|
|
|
661
1133
|
try {
|
|
662
1134
|
this.ensureNotDestroyed();
|
|
663
1135
|
await this.prepare();
|
|
1136
|
+
this.activityRefinement?.reset();
|
|
664
1137
|
this.matchWindowOrThrow().reset();
|
|
665
1138
|
await this.matcherSession.clear();
|
|
666
1139
|
} catch (error) {
|
|
@@ -798,6 +1271,8 @@ var MicrophoneMatcher = class {
|
|
|
798
1271
|
this.diagnostics.readyQueries += 1;
|
|
799
1272
|
let matchedBest = null;
|
|
800
1273
|
let shouldEmitNoMatch = false;
|
|
1274
|
+
let audioMarkerRequestId = null;
|
|
1275
|
+
let associatedActivity = false;
|
|
801
1276
|
const handleMatch = (event) => {
|
|
802
1277
|
if (!event.best) {
|
|
803
1278
|
return;
|
|
@@ -814,8 +1289,23 @@ var MicrophoneMatcher = class {
|
|
|
814
1289
|
try {
|
|
815
1290
|
this.diagnostics.matchRequests += 1;
|
|
816
1291
|
const bestMatchPromise = this.matcherSession.bestMatch(query, this.matchConfig());
|
|
817
|
-
|
|
818
|
-
|
|
1292
|
+
audioMarkerRequestId = this.beginAudioMarkerDetection(captureGeneration);
|
|
1293
|
+
let observedBest;
|
|
1294
|
+
try {
|
|
1295
|
+
observedBest = await bestMatchPromise;
|
|
1296
|
+
} catch (error) {
|
|
1297
|
+
if (audioMarkerRequestId !== null) {
|
|
1298
|
+
this.activityRefinement?.discardRequest(audioMarkerRequestId);
|
|
1299
|
+
}
|
|
1300
|
+
throw error;
|
|
1301
|
+
}
|
|
1302
|
+
if (audioMarkerRequestId !== null) {
|
|
1303
|
+
associatedActivity = this.activityRefinement?.associate(
|
|
1304
|
+
audioMarkerRequestId,
|
|
1305
|
+
observedBest,
|
|
1306
|
+
captureGeneration
|
|
1307
|
+
) ?? false;
|
|
1308
|
+
}
|
|
819
1309
|
const distinctBest = this.withAudioMarkerForRequest(matchedBest, audioMarkerRequestId);
|
|
820
1310
|
matchedBest = distinctBest;
|
|
821
1311
|
} finally {
|
|
@@ -827,9 +1317,21 @@ var MicrophoneMatcher = class {
|
|
|
827
1317
|
}
|
|
828
1318
|
if (matchedBest) {
|
|
829
1319
|
this.events.emit("match", { best: matchedBest });
|
|
830
|
-
|
|
1320
|
+
this.activityRecognitionId += 1;
|
|
1321
|
+
this.reportCampaign(
|
|
1322
|
+
this.activityRecognitionId,
|
|
1323
|
+
matchedBest,
|
|
1324
|
+
captureGeneration,
|
|
1325
|
+
audioMarkerRequestId
|
|
1326
|
+
);
|
|
831
1327
|
} else if (shouldEmitNoMatch) {
|
|
1328
|
+
if (audioMarkerRequestId !== null && !associatedActivity) {
|
|
1329
|
+
this.activityRefinement?.discardRequest(audioMarkerRequestId);
|
|
1330
|
+
}
|
|
1331
|
+
this.activityRefinement?.clearActive(captureGeneration);
|
|
832
1332
|
this.events.emit("nomatch", {});
|
|
1333
|
+
} else if (audioMarkerRequestId !== null && !associatedActivity) {
|
|
1334
|
+
this.activityRefinement?.discardRequest(audioMarkerRequestId);
|
|
833
1335
|
}
|
|
834
1336
|
} while (this.pendingMatch || this.matchWindowOrThrow().hasReadyQuery());
|
|
835
1337
|
} catch (error) {
|
|
@@ -849,6 +1351,7 @@ var MicrophoneMatcher = class {
|
|
|
849
1351
|
this.latestAudioMarker = null;
|
|
850
1352
|
this.latestAudioMarkerDetection = null;
|
|
851
1353
|
this.audioMarkerRequestId += 1;
|
|
1354
|
+
this.activityRefinement?.reset();
|
|
852
1355
|
this.matchWindowOrThrow().reset();
|
|
853
1356
|
await this.extractor?.reset();
|
|
854
1357
|
}
|
|
@@ -866,6 +1369,7 @@ var MicrophoneMatcher = class {
|
|
|
866
1369
|
}
|
|
867
1370
|
const requestId = this.audioMarkerRequestId + 1;
|
|
868
1371
|
this.audioMarkerRequestId = requestId;
|
|
1372
|
+
this.activityRefinement?.registerRequest(requestId);
|
|
869
1373
|
const markerPcm = float32ToPcm16Le(this.markerPcmBuffer);
|
|
870
1374
|
void this.detectAudioMarkerForRequest(captureGeneration, requestId, markerPcm);
|
|
871
1375
|
return requestId;
|
|
@@ -881,8 +1385,10 @@ var MicrophoneMatcher = class {
|
|
|
881
1385
|
}
|
|
882
1386
|
this.latestAudioMarkerDetection = { requestId, detection };
|
|
883
1387
|
this.emitAudioMarkerIfChanged(detection);
|
|
1388
|
+
this.activityRefinement?.refine(requestId, detection, captureGeneration);
|
|
884
1389
|
return detection;
|
|
885
1390
|
} catch (error) {
|
|
1391
|
+
this.activityRefinement?.settleRequest(requestId);
|
|
886
1392
|
emitError(this.events, "audiomarker", error);
|
|
887
1393
|
return null;
|
|
888
1394
|
}
|
|
@@ -975,19 +1481,17 @@ var MicrophoneMatcher = class {
|
|
|
975
1481
|
}
|
|
976
1482
|
return this.matchWindow;
|
|
977
1483
|
}
|
|
978
|
-
|
|
1484
|
+
reportCampaign(recognitionId, match, captureGeneration, audioMarkerRequestId) {
|
|
979
1485
|
const reporter = this.options.activityReporter;
|
|
980
1486
|
if (!reporter) {
|
|
981
1487
|
return;
|
|
982
1488
|
}
|
|
983
|
-
|
|
984
|
-
|
|
985
|
-
|
|
986
|
-
|
|
987
|
-
|
|
988
|
-
|
|
989
|
-
emitError(this.events, "activity", error);
|
|
990
|
-
}
|
|
1489
|
+
this.activityRefinement?.report(
|
|
1490
|
+
recognitionId,
|
|
1491
|
+
match,
|
|
1492
|
+
captureGeneration,
|
|
1493
|
+
audioMarkerRequestId
|
|
1494
|
+
);
|
|
991
1495
|
}
|
|
992
1496
|
};
|
|
993
1497
|
function appendFloat32(left, right) {
|
|
@@ -1025,11 +1529,31 @@ function resolveStorage2(storage) {
|
|
|
1025
1529
|
}
|
|
1026
1530
|
return globalThis.localStorage;
|
|
1027
1531
|
}
|
|
1532
|
+
function formatUuidV4(bytes) {
|
|
1533
|
+
bytes[6] = bytes[6] & 15 | 64;
|
|
1534
|
+
bytes[8] = bytes[8] & 63 | 128;
|
|
1535
|
+
const hex = Array.from(bytes, (byte) => byte.toString(16).padStart(2, "0")).join("");
|
|
1536
|
+
return [
|
|
1537
|
+
hex.slice(0, 8),
|
|
1538
|
+
hex.slice(8, 12),
|
|
1539
|
+
hex.slice(12, 16),
|
|
1540
|
+
hex.slice(16, 20),
|
|
1541
|
+
hex.slice(20)
|
|
1542
|
+
].join("-");
|
|
1543
|
+
}
|
|
1028
1544
|
function defaultGenerateSessionId() {
|
|
1029
|
-
|
|
1030
|
-
|
|
1545
|
+
const crypto = globalThis.crypto;
|
|
1546
|
+
if (typeof crypto !== "undefined") {
|
|
1547
|
+
if (typeof crypto.randomUUID === "function") {
|
|
1548
|
+
return crypto.randomUUID();
|
|
1549
|
+
}
|
|
1550
|
+
if (typeof crypto.getRandomValues === "function") {
|
|
1551
|
+
return formatUuidV4(crypto.getRandomValues(new Uint8Array(16)));
|
|
1552
|
+
}
|
|
1031
1553
|
}
|
|
1032
|
-
|
|
1554
|
+
throw new Error(
|
|
1555
|
+
"Secure random number generation is unavailable; provide a cryptographically secure `generateSessionId`"
|
|
1556
|
+
);
|
|
1033
1557
|
}
|
|
1034
1558
|
function createLocalStorageSessionManager(options) {
|
|
1035
1559
|
const storage = resolveStorage2(options.storage);
|
|
@@ -1122,7 +1646,8 @@ function resolveRecognizerOptions(options) {
|
|
|
1122
1646
|
const activityReporter = activityEndpoint === void 0 ? void 0 : {
|
|
1123
1647
|
...customActivityReporter,
|
|
1124
1648
|
endpoint: activityEndpoint,
|
|
1125
|
-
fetch: customActivityReporter?.fetch ?? sharedFetch
|
|
1649
|
+
fetch: customActivityReporter?.fetch ?? sharedFetch,
|
|
1650
|
+
refinement: customActivityReporter ? customActivityReporter.refinement : {}
|
|
1126
1651
|
};
|
|
1127
1652
|
return {
|
|
1128
1653
|
...options,
|
|
@@ -1295,6 +1820,7 @@ var AudioRecognizer = class {
|
|
|
1295
1820
|
// src/index.ts
|
|
1296
1821
|
import { AudioFingerprintType as AudioFingerprintType3 } from "@sorisdk/matcher";
|
|
1297
1822
|
export {
|
|
1823
|
+
ACTIVITY_REFINEMENT_WINDOW_MS,
|
|
1298
1824
|
AudioFingerprintType3 as AudioFingerprintType,
|
|
1299
1825
|
AudioRecognizer,
|
|
1300
1826
|
DEFAULT_BROWSER_AUDIOPACK_VERSION,
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@sorisdk/web-audio",
|
|
3
|
-
"version": "0.6.
|
|
3
|
+
"version": "0.6.5",
|
|
4
4
|
"description": "Web SDK for browser-based audio recognition with SORI API",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./dist/index.js",
|
|
@@ -34,13 +34,13 @@
|
|
|
34
34
|
"license": "SEE LICENSE IN LICENSE.md",
|
|
35
35
|
"homepage": "https://docs.soriapi.com/ko/integration/web",
|
|
36
36
|
"dependencies": {
|
|
37
|
-
"@sorisdk/
|
|
38
|
-
"@sorisdk/
|
|
37
|
+
"@sorisdk/afpgen": "0.6.5",
|
|
38
|
+
"@sorisdk/matcher": "0.6.5"
|
|
39
39
|
},
|
|
40
40
|
"devDependencies": {
|
|
41
41
|
"tsup": "^8.5.1",
|
|
42
42
|
"typescript": "^5.8.3",
|
|
43
|
-
"vitest": "^3.2.
|
|
43
|
+
"vitest": "^3.2.6"
|
|
44
44
|
},
|
|
45
45
|
"scripts": {
|
|
46
46
|
"build:wasm": "node ../../scripts/build-wasm-package.mjs web-audio",
|