@sorisdk/web-audio 0.6.4 → 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 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.4/sori-web-audio.mjs";
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.4/sori-web-audio.mjs")`
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.4/sori-web-audio.mjs"
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,58 @@ 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
+
162
214
  ## Session identifiers
163
215
 
164
216
  `AudioRecognizer` creates one pseudonymous session identifier per application
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
@@ -314,6 +314,22 @@ var FingerprintMatchWindow = class {
314
314
  };
315
315
 
316
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
+ };
317
333
  function isRecord2(value) {
318
334
  return typeof value === "object" && value !== null;
319
335
  }
@@ -333,11 +349,15 @@ async function resolveToken(token) {
333
349
  }
334
350
  return resolved;
335
351
  }
336
- async function resolveRequestPayload(reporter, match) {
337
- const payload = reporter.mapMatchToRequest ? await reporter.mapMatchToRequest(match) : {
352
+ function defaultRequestPayload(match) {
353
+ const marker = match.audioMarker?.code;
354
+ return {
338
355
  type: "material",
339
- material_id: match.name
356
+ material_id: match.name,
357
+ ...typeof marker === "string" && marker.length > 0 ? { trait: { marker } } : {}
340
358
  };
359
+ }
360
+ function validateRequestPayload(payload) {
341
361
  if (payload === null) {
342
362
  return null;
343
363
  }
@@ -346,6 +366,30 @@ async function resolveRequestPayload(reporter, match) {
346
366
  }
347
367
  return payload;
348
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
+ }
349
393
  function defaultCampaignMapper(payload, match) {
350
394
  const campaign = payload.campaign;
351
395
  if (!isRecord2(campaign)) {
@@ -358,42 +402,462 @@ function defaultCampaignMapper(payload, match) {
358
402
  raw: payload
359
403
  };
360
404
  }
361
- async function reportMatchedMaterialActivity(reporter, match) {
362
- const payload = await resolveRequestPayload(reporter, match);
363
- if (!payload) {
364
- return null;
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));
365
414
  }
366
- const headers = new Headers(
367
- reporter.headers ? typeof reporter.headers === "function" ? await reporter.headers() : reporter.headers : void 0
368
- );
369
415
  headers.set("authorization", `Bearer ${await resolveToken(reporter.token)}`);
370
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;
371
485
  const response = await resolveFetch(reporter.fetch)(reporter.endpoint, {
372
486
  method: "POST",
373
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,
374
514
  body: JSON.stringify(payload)
375
515
  });
376
- if (!response.ok) {
377
- throw new Error(`Activity reporting failed: HTTP ${response.status}`);
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
+ );
378
527
  }
379
- const responseText = await response.text();
380
- if (responseText.trim().length === 0) {
381
- return null;
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
+ }
382
566
  }
383
- let data;
384
- try {
385
- data = JSON.parse(responseText);
386
- } catch {
387
- throw new Error("Activity reporting failed: invalid JSON response");
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
+ );
388
645
  }
389
- if (!isRecord2(data)) {
390
- throw new Error("Activity reporting failed: expected an object response");
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;
391
659
  }
392
- if (reporter.mapResponseToCampaign) {
393
- return reporter.mapResponseToCampaign(data, match);
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
+ }
394
677
  }
395
- return defaultCampaignMapper(data, match);
396
- }
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
+ };
397
861
 
398
862
  // src/microphone.ts
399
863
  var WORKLET_PROCESSOR_NAME = "sori-microphone-capture";
@@ -518,11 +982,11 @@ var defaultAudioGraphFactory = async (options) => {
518
982
  };
519
983
 
520
984
  // src/microphone-matcher.ts
521
- function normalizeError(error) {
985
+ function normalizeError2(error) {
522
986
  return error instanceof Error ? error : new Error(String(error));
523
987
  }
524
988
  function emitError(emitter, phase, error) {
525
- const normalized = normalizeError(error);
989
+ const normalized = normalizeError2(error);
526
990
  emitter.emit("error", { phase, error: normalized });
527
991
  return normalized;
528
992
  }
@@ -540,6 +1004,7 @@ var MicrophoneMatcher = class {
540
1004
  captureSampleRate;
541
1005
  audioGraphFactory;
542
1006
  packSource;
1007
+ activityRefinement;
543
1008
  options;
544
1009
  extractor = null;
545
1010
  graphController = null;
@@ -558,6 +1023,7 @@ var MicrophoneMatcher = class {
558
1023
  latestAudioMarker = null;
559
1024
  latestAudioMarkerDetection = null;
560
1025
  audioMarkerRequestId = 0;
1026
+ activityRecognitionId = 0;
561
1027
  hopSize = 0;
562
1028
  diagnostics = {
563
1029
  sampleCallbacks: 0,
@@ -579,6 +1045,11 @@ var MicrophoneMatcher = class {
579
1045
  this.suspendContextOnStop = options.suspendContextOnStop ?? true;
580
1046
  this.audioGraphFactory = defaultAudioGraphFactory;
581
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;
582
1053
  }
583
1054
  on(eventName, listener) {
584
1055
  this.events.on(eventName, listener);
@@ -662,6 +1133,7 @@ var MicrophoneMatcher = class {
662
1133
  try {
663
1134
  this.ensureNotDestroyed();
664
1135
  await this.prepare();
1136
+ this.activityRefinement?.reset();
665
1137
  this.matchWindowOrThrow().reset();
666
1138
  await this.matcherSession.clear();
667
1139
  } catch (error) {
@@ -799,6 +1271,8 @@ var MicrophoneMatcher = class {
799
1271
  this.diagnostics.readyQueries += 1;
800
1272
  let matchedBest = null;
801
1273
  let shouldEmitNoMatch = false;
1274
+ let audioMarkerRequestId = null;
1275
+ let associatedActivity = false;
802
1276
  const handleMatch = (event) => {
803
1277
  if (!event.best) {
804
1278
  return;
@@ -815,8 +1289,23 @@ var MicrophoneMatcher = class {
815
1289
  try {
816
1290
  this.diagnostics.matchRequests += 1;
817
1291
  const bestMatchPromise = this.matcherSession.bestMatch(query, this.matchConfig());
818
- const audioMarkerRequestId = this.beginAudioMarkerDetection(captureGeneration);
819
- await bestMatchPromise;
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
+ }
820
1309
  const distinctBest = this.withAudioMarkerForRequest(matchedBest, audioMarkerRequestId);
821
1310
  matchedBest = distinctBest;
822
1311
  } finally {
@@ -828,9 +1317,21 @@ var MicrophoneMatcher = class {
828
1317
  }
829
1318
  if (matchedBest) {
830
1319
  this.events.emit("match", { best: matchedBest });
831
- void this.reportCampaign(matchedBest, captureGeneration);
1320
+ this.activityRecognitionId += 1;
1321
+ this.reportCampaign(
1322
+ this.activityRecognitionId,
1323
+ matchedBest,
1324
+ captureGeneration,
1325
+ audioMarkerRequestId
1326
+ );
832
1327
  } else if (shouldEmitNoMatch) {
1328
+ if (audioMarkerRequestId !== null && !associatedActivity) {
1329
+ this.activityRefinement?.discardRequest(audioMarkerRequestId);
1330
+ }
1331
+ this.activityRefinement?.clearActive(captureGeneration);
833
1332
  this.events.emit("nomatch", {});
1333
+ } else if (audioMarkerRequestId !== null && !associatedActivity) {
1334
+ this.activityRefinement?.discardRequest(audioMarkerRequestId);
834
1335
  }
835
1336
  } while (this.pendingMatch || this.matchWindowOrThrow().hasReadyQuery());
836
1337
  } catch (error) {
@@ -850,6 +1351,7 @@ var MicrophoneMatcher = class {
850
1351
  this.latestAudioMarker = null;
851
1352
  this.latestAudioMarkerDetection = null;
852
1353
  this.audioMarkerRequestId += 1;
1354
+ this.activityRefinement?.reset();
853
1355
  this.matchWindowOrThrow().reset();
854
1356
  await this.extractor?.reset();
855
1357
  }
@@ -867,6 +1369,7 @@ var MicrophoneMatcher = class {
867
1369
  }
868
1370
  const requestId = this.audioMarkerRequestId + 1;
869
1371
  this.audioMarkerRequestId = requestId;
1372
+ this.activityRefinement?.registerRequest(requestId);
870
1373
  const markerPcm = float32ToPcm16Le(this.markerPcmBuffer);
871
1374
  void this.detectAudioMarkerForRequest(captureGeneration, requestId, markerPcm);
872
1375
  return requestId;
@@ -882,8 +1385,10 @@ var MicrophoneMatcher = class {
882
1385
  }
883
1386
  this.latestAudioMarkerDetection = { requestId, detection };
884
1387
  this.emitAudioMarkerIfChanged(detection);
1388
+ this.activityRefinement?.refine(requestId, detection, captureGeneration);
885
1389
  return detection;
886
1390
  } catch (error) {
1391
+ this.activityRefinement?.settleRequest(requestId);
887
1392
  emitError(this.events, "audiomarker", error);
888
1393
  return null;
889
1394
  }
@@ -976,19 +1481,17 @@ var MicrophoneMatcher = class {
976
1481
  }
977
1482
  return this.matchWindow;
978
1483
  }
979
- async reportCampaign(match, captureGeneration) {
1484
+ reportCampaign(recognitionId, match, captureGeneration, audioMarkerRequestId) {
980
1485
  const reporter = this.options.activityReporter;
981
1486
  if (!reporter) {
982
1487
  return;
983
1488
  }
984
- try {
985
- const campaign = await reportMatchedMaterialActivity(reporter, match);
986
- if (campaign && captureGeneration === this.captureGeneration && this.running) {
987
- this.events.emit("campaign", campaign);
988
- }
989
- } catch (error) {
990
- emitError(this.events, "activity", error);
991
- }
1489
+ this.activityRefinement?.report(
1490
+ recognitionId,
1491
+ match,
1492
+ captureGeneration,
1493
+ audioMarkerRequestId
1494
+ );
992
1495
  }
993
1496
  };
994
1497
  function appendFloat32(left, right) {
@@ -1143,7 +1646,8 @@ function resolveRecognizerOptions(options) {
1143
1646
  const activityReporter = activityEndpoint === void 0 ? void 0 : {
1144
1647
  ...customActivityReporter,
1145
1648
  endpoint: activityEndpoint,
1146
- fetch: customActivityReporter?.fetch ?? sharedFetch
1649
+ fetch: customActivityReporter?.fetch ?? sharedFetch,
1650
+ refinement: customActivityReporter ? customActivityReporter.refinement : {}
1147
1651
  };
1148
1652
  return {
1149
1653
  ...options,
@@ -1316,6 +1820,7 @@ var AudioRecognizer = class {
1316
1820
  // src/index.ts
1317
1821
  import { AudioFingerprintType as AudioFingerprintType3 } from "@sorisdk/matcher";
1318
1822
  export {
1823
+ ACTIVITY_REFINEMENT_WINDOW_MS,
1319
1824
  AudioFingerprintType3 as AudioFingerprintType,
1320
1825
  AudioRecognizer,
1321
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.4",
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,8 +34,8 @@
34
34
  "license": "SEE LICENSE IN LICENSE.md",
35
35
  "homepage": "https://docs.soriapi.com/ko/integration/web",
36
36
  "dependencies": {
37
- "@sorisdk/matcher": "0.6.4",
38
- "@sorisdk/afpgen": "0.6.4"
37
+ "@sorisdk/afpgen": "0.6.5",
38
+ "@sorisdk/matcher": "0.6.5"
39
39
  },
40
40
  "devDependencies": {
41
41
  "tsup": "^8.5.1",