@sorisdk/web-audio 0.6.4 → 0.6.7

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js CHANGED
@@ -181,6 +181,18 @@ function getBrowserMediaDevices() {
181
181
  }
182
182
 
183
183
  // src/match-window.ts
184
+ var FINGERPRINT_LEVEL_BYTE_LENGTH = 8;
185
+ function getTypedArrayByteLengthGetter() {
186
+ const getter = Object.getOwnPropertyDescriptor(
187
+ Object.getPrototypeOf(Uint8Array.prototype),
188
+ "byteLength"
189
+ )?.get;
190
+ if (!getter) {
191
+ throw new Error("Uint8Array byteLength intrinsic is unavailable");
192
+ }
193
+ return getter;
194
+ }
195
+ var typedArrayByteLengthGetter = getTypedArrayByteLengthGetter();
184
196
  var cachedBindings = null;
185
197
  var initPromise = null;
186
198
  var testInjectedWasmModule = null;
@@ -197,18 +209,46 @@ var defaultLoader = async () => {
197
209
  const loaderModule = await import("./default-loader.js");
198
210
  return loaderModule.default();
199
211
  };
212
+ function calculateMaxAppendFingerprintByteLength(options) {
213
+ const hopSize = options.afpgenConfig.hopSize ?? 1e3;
214
+ const levelCount = options.afpgenConfig.levelCount ?? 2;
215
+ const hopDurationMs = hopSize / options.sampleRate * 1e3;
216
+ const hopsInWindow = Math.ceil(options.matchWindowMs / hopDurationMs);
217
+ return Math.max(1, hopsInWindow) * levelCount * FINGERPRINT_LEVEL_BYTE_LENGTH;
218
+ }
219
+ function appendLimitError(byteLength, maxByteLength) {
220
+ return new RangeError(
221
+ `Fingerprint append byteLength ${byteLength} exceeds the per-instance limit of ${maxByteLength} bytes`
222
+ );
223
+ }
224
+ function intrinsicUint8ArrayByteLength(bytes) {
225
+ return typedArrayByteLengthGetter.call(bytes);
226
+ }
200
227
  var WrappedFingerprintMatchWindow = class {
201
- constructor(inner) {
228
+ constructor(inner, options) {
202
229
  this.inner = inner;
230
+ const maxLengthFn = getCallable(this.inner, [
231
+ "maxAppendFingerprintByteLength",
232
+ "max_append_fingerprint_byte_length"
233
+ ]);
234
+ this.maxAppendByteLength = maxLengthFn ? Number(maxLengthFn.call(this.inner)) : calculateMaxAppendFingerprintByteLength(options);
203
235
  }
204
236
  inner;
237
+ maxAppendByteLength;
205
238
  appendFingerprint(bytes) {
239
+ const byteLength = intrinsicUint8ArrayByteLength(bytes);
240
+ if (byteLength > this.maxAppendByteLength) {
241
+ throw appendLimitError(byteLength, this.maxAppendByteLength);
242
+ }
206
243
  const fn = getCallable(this.inner, ["appendFingerprint", "append_fingerprint"]);
207
244
  if (!fn) {
208
245
  throw new Error("web-audio wasm window is missing appendFingerprint");
209
246
  }
210
247
  fn.call(this.inner, bytes);
211
248
  }
249
+ maxAppendFingerprintByteLength() {
250
+ return this.maxAppendByteLength;
251
+ }
212
252
  hasReadyQuery() {
213
253
  const fn = getCallable(this.inner, ["hasReadyQuery", "has_ready_query"]);
214
254
  if (!fn) {
@@ -261,7 +301,8 @@ function createBindingsFromModule(moduleLike) {
261
301
  options.afpgenConfig,
262
302
  options.matchWindowMs,
263
303
  options.matchStrideMs
264
- )
304
+ ),
305
+ options
265
306
  );
266
307
  }
267
308
  };
@@ -314,6 +355,22 @@ var FingerprintMatchWindow = class {
314
355
  };
315
356
 
316
357
  // src/activity.ts
358
+ var ActivityReportingError = class extends Error {
359
+ status;
360
+ detail;
361
+ constructor(message, status = null, detail = null) {
362
+ super(message);
363
+ this.name = "ActivityReportingError";
364
+ this.status = status;
365
+ this.detail = detail;
366
+ }
367
+ };
368
+ var ActivityReportingCancelledError = class extends Error {
369
+ constructor() {
370
+ super("Activity reporting was cancelled before transport");
371
+ this.name = "ActivityReportingCancelledError";
372
+ }
373
+ };
317
374
  function isRecord2(value) {
318
375
  return typeof value === "object" && value !== null;
319
376
  }
@@ -333,11 +390,23 @@ async function resolveToken(token) {
333
390
  }
334
391
  return resolved;
335
392
  }
336
- async function resolveRequestPayload(reporter, match) {
337
- const payload = reporter.mapMatchToRequest ? await reporter.mapMatchToRequest(match) : {
393
+ function defaultRequestPayload(match) {
394
+ const marker = match.audioMarker?.code;
395
+ return {
338
396
  type: "material",
339
- material_id: match.name
397
+ material_id: match.name,
398
+ ...typeof marker === "string" && marker.length > 0 ? { trait: { marker } } : {}
340
399
  };
400
+ }
401
+ function captureActivityRecognitionTiming(match) {
402
+ const recognizedAtMillis = Date.now();
403
+ const position = match.position;
404
+ return Object.freeze({
405
+ recognizedAt: new Date(recognizedAtMillis).toISOString(),
406
+ position
407
+ });
408
+ }
409
+ function validateRequestPayload(payload) {
341
410
  if (payload === null) {
342
411
  return null;
343
412
  }
@@ -346,6 +415,40 @@ async function resolveRequestPayload(reporter, match) {
346
415
  }
347
416
  return payload;
348
417
  }
418
+ async function resolveCreateRequestPayload(reporter, match) {
419
+ const payload = validateRequestPayload(
420
+ reporter.mapMatchToRequest ? await reporter.mapMatchToRequest(match) : defaultRequestPayload(match)
421
+ );
422
+ if (!payload) {
423
+ return null;
424
+ }
425
+ const {
426
+ position: _ignoredPosition,
427
+ recognized_at: _ignoredRecognizedAt,
428
+ refinement_expected: _ignoredRefinementExpected,
429
+ ...requestPayload
430
+ } = payload;
431
+ return requestPayload;
432
+ }
433
+ async function resolveRefineRequestPayload(reporter, match, context) {
434
+ const refinement = reporter.refinement;
435
+ if (!refinement) {
436
+ return null;
437
+ }
438
+ const payload = validateRequestPayload(
439
+ refinement.mapMatchToRequest ? await refinement.mapMatchToRequest(match, context) : reporter.mapMatchToRequest ? await reporter.mapMatchToRequest(match) : defaultRequestPayload(match)
440
+ );
441
+ if (!payload) {
442
+ return null;
443
+ }
444
+ const {
445
+ position: _ignoredPosition,
446
+ recognized_at: _ignoredRecognizedAt,
447
+ refinement_expected: _ignoredRefinementExpected,
448
+ ...requestPayload
449
+ } = payload;
450
+ return requestPayload;
451
+ }
349
452
  function defaultCampaignMapper(payload, match) {
350
453
  const campaign = payload.campaign;
351
454
  if (!isRecord2(campaign)) {
@@ -358,42 +461,608 @@ function defaultCampaignMapper(payload, match) {
358
461
  raw: payload
359
462
  };
360
463
  }
361
- async function reportMatchedMaterialActivity(reporter, match) {
362
- const payload = await resolveRequestPayload(reporter, match);
363
- if (!payload) {
364
- return null;
464
+ async function resolveHeaders(reporter, refinement = false) {
465
+ const refinementHeaders = reporter.refinement && reporter.refinement.headers;
466
+ const baseHeaders = reporter.headers ? typeof reporter.headers === "function" ? await reporter.headers() : reporter.headers : void 0;
467
+ const headers = new Headers(baseHeaders);
468
+ if (refinement && refinementHeaders !== void 0) {
469
+ const overrides = new Headers(
470
+ typeof refinementHeaders === "function" ? await refinementHeaders() : refinementHeaders
471
+ );
472
+ overrides.forEach((value, name) => headers.set(name, value));
365
473
  }
366
- const headers = new Headers(
367
- reporter.headers ? typeof reporter.headers === "function" ? await reporter.headers() : reporter.headers : void 0
368
- );
369
474
  headers.set("authorization", `Bearer ${await resolveToken(reporter.token)}`);
370
475
  headers.set("content-type", "application/json");
476
+ return headers;
477
+ }
478
+ async function parseActivityResponse(response, operation) {
479
+ const responseText = await response.text();
480
+ let data = null;
481
+ if (responseText.trim().length > 0) {
482
+ try {
483
+ data = JSON.parse(responseText);
484
+ } catch {
485
+ throw new ActivityReportingError(
486
+ `Activity ${operation} failed: invalid JSON response`,
487
+ response.status
488
+ );
489
+ }
490
+ }
491
+ const succeeded = operation === "refine" ? response.status === 200 : response.ok;
492
+ if (!succeeded) {
493
+ const detail = isRecord2(data) && typeof data.detail === "string" ? data.detail : null;
494
+ throw new ActivityReportingError(
495
+ `Activity ${operation} failed: HTTP ${response.status}${detail ? ` (${detail})` : ""}`,
496
+ response.status,
497
+ detail
498
+ );
499
+ }
500
+ if (data === null) {
501
+ return null;
502
+ }
503
+ if (!isRecord2(data)) {
504
+ throw new ActivityReportingError(
505
+ `Activity ${operation} failed: expected an object response`,
506
+ response.status
507
+ );
508
+ }
509
+ return data;
510
+ }
511
+ function responseActivityId(data, campaign) {
512
+ if (data && typeof data.activity_id === "string" && data.activity_id.length > 0) {
513
+ return data.activity_id;
514
+ }
515
+ return campaign?.activityId && campaign.activityId.length > 0 ? campaign.activityId : null;
516
+ }
517
+ async function mapCreateResponse(reporter, data, match, refinementExpected) {
518
+ if (!data) {
519
+ return {
520
+ activityId: null,
521
+ campaign: null,
522
+ creationAttempted: true,
523
+ refinementExpected
524
+ };
525
+ }
526
+ const campaign = reporter.mapResponseToCampaign ? await reporter.mapResponseToCampaign(data, match) : defaultCampaignMapper(data, match);
527
+ return {
528
+ activityId: responseActivityId(data, campaign),
529
+ campaign,
530
+ creationAttempted: true,
531
+ refinementExpected
532
+ };
533
+ }
534
+ async function resolveRefinementEndpoint(reporter, activityId) {
535
+ const configured = reporter.refinement && reporter.refinement.endpoint;
536
+ if (typeof configured === "function") {
537
+ return configured(activityId);
538
+ }
539
+ const prefix = String(configured ?? reporter.endpoint);
540
+ return `${prefix.endsWith("/") ? prefix : `${prefix}/`}${encodeURIComponent(activityId)}`;
541
+ }
542
+ async function createMatchedMaterialActivity(reporter, match, refinementExpected = false, recognitionTiming = captureActivityRecognitionTiming(match)) {
543
+ const payload = await resolveCreateRequestPayload(reporter, match);
544
+ if (!payload) {
545
+ return {
546
+ activityId: null,
547
+ campaign: null,
548
+ creationAttempted: false,
549
+ refinementExpected: false
550
+ };
551
+ }
552
+ const headers = await resolveHeaders(reporter);
553
+ const actualMarker = match.audioMarker?.code;
554
+ const payloadMarker = payload.trait?.marker;
555
+ const hasMarker = typeof actualMarker === "string" && actualMarker.length > 0 || typeof payloadMarker === "string" && payloadMarker.length > 0;
556
+ const lifecycleOpen = typeof refinementExpected === "function" ? refinementExpected() : refinementExpected;
557
+ const requestRefinementExpected = Boolean(reporter.refinement && lifecycleOpen && !hasMarker);
558
+ const requestPayload = {
559
+ ...payload,
560
+ recognized_at: recognitionTiming.recognizedAt,
561
+ position: recognitionTiming.position,
562
+ ...requestRefinementExpected ? { refinement_expected: true } : {}
563
+ };
371
564
  const response = await resolveFetch(reporter.fetch)(reporter.endpoint, {
372
565
  method: "POST",
373
566
  headers,
567
+ body: JSON.stringify(requestPayload)
568
+ });
569
+ return mapCreateResponse(
570
+ reporter,
571
+ await parseActivityResponse(response, "create"),
572
+ match,
573
+ requestRefinementExpected
574
+ );
575
+ }
576
+ async function refineMatchedMaterialActivity(reporter, match, context, isActive = () => true) {
577
+ const refinement = reporter.refinement;
578
+ if (!refinement) {
579
+ return { activityId: null, campaign: null };
580
+ }
581
+ const payload = await resolveRefineRequestPayload(reporter, match, context);
582
+ if (!payload) {
583
+ return { activityId: null, campaign: null };
584
+ }
585
+ const endpoint = await resolveRefinementEndpoint(reporter, context.activityId);
586
+ const headers = await resolveHeaders(reporter, true);
587
+ if (!isActive()) {
588
+ throw new ActivityReportingCancelledError();
589
+ }
590
+ const response = await resolveFetch(refinement.fetch ?? reporter.fetch)(endpoint, {
591
+ method: "PUT",
592
+ headers,
374
593
  body: JSON.stringify(payload)
375
594
  });
376
- if (!response.ok) {
377
- throw new Error(`Activity reporting failed: HTTP ${response.status}`);
595
+ const data = await parseActivityResponse(response, "refine");
596
+ if (!data) {
597
+ throw new ActivityReportingError("Activity refine failed: empty response", response.status);
598
+ }
599
+ const mappedCampaign = refinement.mapResponseToCampaign ? await refinement.mapResponseToCampaign(data, match, context) : reporter.mapResponseToCampaign ? await reporter.mapResponseToCampaign(data, match) : defaultCampaignMapper(data, match);
600
+ const activityId = responseActivityId(data, mappedCampaign);
601
+ if (activityId !== context.activityId) {
602
+ throw new ActivityReportingError(
603
+ "Activity refine failed: response activity_id does not match the requested activity",
604
+ response.status
605
+ );
378
606
  }
379
- const responseText = await response.text();
380
- if (responseText.trim().length === 0) {
381
- return null;
607
+ const campaign = mappedCampaign ? { ...mappedCampaign, activityId: context.activityId } : null;
608
+ return { activityId, campaign };
609
+ }
610
+
611
+ // src/activity-refinement.ts
612
+ var ACTIVITY_REFINEMENT_WINDOW_MS = 3e4;
613
+ var ACTIVITY_CONTINUITY_GAP_MS = 3e4;
614
+ var DEFAULT_MAX_REFINEMENT_ATTEMPTS = 2;
615
+ var MAX_REFINEMENT_ATTEMPTS = 10;
616
+ function normalizeError(error) {
617
+ return error instanceof Error ? error : new Error(String(error));
618
+ }
619
+ function statusAndDetail(error) {
620
+ return error instanceof ActivityReportingError ? { status: error.status, detail: error.detail } : { status: null, detail: null };
621
+ }
622
+ function defaultShouldRetry(context) {
623
+ return context.status === null || context.status >= 500 && context.status <= 599;
624
+ }
625
+ function isStableTerminalStatus(status) {
626
+ return status === 404 || status === 409 || status === 410;
627
+ }
628
+ var ActivityRefinementCoordinator = class {
629
+ reporter;
630
+ callbacks;
631
+ lifecycles = /* @__PURE__ */ new Map();
632
+ requestLifecycles = /* @__PURE__ */ new Map();
633
+ pendingMarkers = /* @__PURE__ */ new Map();
634
+ registeredRequests = /* @__PURE__ */ new Set();
635
+ settledRequests = /* @__PURE__ */ new Set();
636
+ activeLifecycle = null;
637
+ generation = 0;
638
+ constructor(reporter, callbacks) {
639
+ this.reporter = reporter;
640
+ this.callbacks = callbacks;
641
+ }
642
+ registerRequest(requestId) {
643
+ if (this.reporter.refinement) {
644
+ this.registeredRequests.add(requestId);
645
+ }
382
646
  }
383
- let data;
384
- try {
385
- data = JSON.parse(responseText);
386
- } catch {
387
- throw new Error("Activity reporting failed: invalid JSON response");
647
+ report(recognitionId, match, captureGeneration, requestId = null) {
648
+ const coordinatorGeneration = this.generation;
649
+ const hasMarker = typeof match.audioMarker?.code === "string" && match.audioMarker.code.length > 0;
650
+ const refinement = this.reporter.refinement;
651
+ const requestCanRefine = requestId !== null && !this.settledRequests.has(requestId);
652
+ const pendingMarker = requestId === null ? void 0 : this.pendingMarkers.get(requestId);
653
+ if (requestId !== null) {
654
+ this.pendingMarkers.delete(requestId);
655
+ this.registeredRequests.delete(requestId);
656
+ this.settledRequests.delete(requestId);
657
+ }
658
+ if (refinement && this.activeLifecycle) {
659
+ const now = this.now();
660
+ const activeLifecycle = this.activeLifecycle;
661
+ if (this.isContinuousMatch(activeLifecycle, match, captureGeneration, now)) {
662
+ this.renewContinuity(activeLifecycle, now);
663
+ if (requestId !== null) {
664
+ if (requestCanRefine && this.canAcceptMarker(activeLifecycle)) {
665
+ this.requestLifecycles.set(requestId, activeLifecycle);
666
+ } else {
667
+ this.requestLifecycles.delete(requestId);
668
+ }
669
+ }
670
+ if (pendingMarker) {
671
+ this.claimMarker(activeLifecycle, pendingMarker);
672
+ }
673
+ if (hasMarker && match.audioMarker) {
674
+ this.claimMarker(activeLifecycle, match.audioMarker);
675
+ }
676
+ return;
677
+ }
678
+ this.sealLifecycle(activeLifecycle);
679
+ }
680
+ const refinementExpected = Boolean(refinement && !hasMarker && requestCanRefine);
681
+ let lifecycle = null;
682
+ if (refinement) {
683
+ const now = this.now();
684
+ lifecycle = {
685
+ recognitionId,
686
+ captureGeneration,
687
+ match,
688
+ deadline: now + ACTIVITY_REFINEMENT_WINDOW_MS,
689
+ lastObservedAt: now,
690
+ activityId: null,
691
+ markerMatch: hasMarker ? match : null,
692
+ markerCode: hasMarker ? match.audioMarker?.code ?? null : null,
693
+ refinementTimer: null,
694
+ continuityTimer: null,
695
+ createSettled: false,
696
+ refinementStarted: false,
697
+ refinementInFlight: false,
698
+ refinementClosed: !refinementExpected,
699
+ sealed: false
700
+ };
701
+ const createdLifecycle = lifecycle;
702
+ if (refinementExpected) {
703
+ lifecycle.refinementTimer = setTimeout(() => {
704
+ this.closeRefinement(createdLifecycle);
705
+ }, ACTIVITY_REFINEMENT_WINDOW_MS);
706
+ }
707
+ this.scheduleContinuityExpiry(lifecycle);
708
+ this.lifecycles.set(recognitionId, lifecycle);
709
+ if (requestId !== null && refinementExpected) {
710
+ this.requestLifecycles.set(requestId, lifecycle);
711
+ }
712
+ this.activeLifecycle = lifecycle;
713
+ if (pendingMarker && refinementExpected) {
714
+ this.claimMarker(lifecycle, pendingMarker);
715
+ }
716
+ }
717
+ const recognitionTiming = captureActivityRecognitionTiming(match);
718
+ void createMatchedMaterialActivity(
719
+ this.reporter,
720
+ match,
721
+ () => Boolean(lifecycle && this.isRefinementActive(lifecycle)),
722
+ recognitionTiming
723
+ ).then(
724
+ (result) => {
725
+ if (coordinatorGeneration === this.generation && result.campaign && this.callbacks.isCurrentCapture(captureGeneration)) {
726
+ this.callbacks.emitCampaign(result.campaign);
727
+ }
728
+ if (coordinatorGeneration !== this.generation || !lifecycle || this.lifecycles.get(recognitionId) !== lifecycle) {
729
+ return;
730
+ }
731
+ lifecycle.createSettled = true;
732
+ lifecycle.activityId = result.activityId;
733
+ if (!result.creationAttempted) {
734
+ this.deleteLifecycle(lifecycle);
735
+ return;
736
+ }
737
+ if (!result.refinementExpected) {
738
+ this.closeRefinement(lifecycle);
739
+ this.maybeDeleteSealed(lifecycle);
740
+ return;
741
+ }
742
+ if (lifecycle.markerMatch) {
743
+ if (lifecycle.activityId) {
744
+ this.startRefinement(lifecycle);
745
+ } else {
746
+ this.failMissingActivityId(lifecycle);
747
+ }
748
+ }
749
+ this.maybeDeleteSealed(lifecycle);
750
+ },
751
+ (error) => {
752
+ if (lifecycle) {
753
+ lifecycle.createSettled = true;
754
+ this.deleteLifecycle(lifecycle);
755
+ }
756
+ if (coordinatorGeneration === this.generation && this.callbacks.isCurrentCapture(captureGeneration)) {
757
+ this.callbacks.emitError("activity", error);
758
+ }
759
+ }
760
+ );
388
761
  }
389
- if (!isRecord2(data)) {
390
- throw new Error("Activity reporting failed: expected an object response");
762
+ associate(requestId, match, captureGeneration) {
763
+ const lifecycle = this.activeLifecycle;
764
+ this.registeredRequests.delete(requestId);
765
+ if (!lifecycle || !match) {
766
+ return false;
767
+ }
768
+ const now = this.now();
769
+ if (!this.isContinuousMatch(lifecycle, match, captureGeneration, now)) {
770
+ this.sealLifecycle(lifecycle);
771
+ return false;
772
+ }
773
+ this.renewContinuity(lifecycle, now);
774
+ const pendingMarker = this.pendingMarkers.get(requestId);
775
+ if (this.canAcceptMarker(lifecycle)) {
776
+ this.requestLifecycles.set(requestId, lifecycle);
777
+ } else {
778
+ this.requestLifecycles.delete(requestId);
779
+ }
780
+ if (pendingMarker && this.canAcceptMarker(lifecycle)) {
781
+ this.pendingMarkers.delete(requestId);
782
+ this.claimMarker(lifecycle, pendingMarker);
783
+ } else if (pendingMarker) {
784
+ this.pendingMarkers.delete(requestId);
785
+ }
786
+ return true;
391
787
  }
392
- if (reporter.mapResponseToCampaign) {
393
- return reporter.mapResponseToCampaign(data, match);
788
+ discardRequest(requestId) {
789
+ this.registeredRequests.delete(requestId);
790
+ this.pendingMarkers.delete(requestId);
791
+ this.requestLifecycles.delete(requestId);
792
+ this.settledRequests.delete(requestId);
793
+ }
794
+ settleRequest(requestId) {
795
+ const awaitingAssociation = this.registeredRequests.delete(requestId);
796
+ this.pendingMarkers.delete(requestId);
797
+ this.requestLifecycles.delete(requestId);
798
+ if (awaitingAssociation) {
799
+ this.settledRequests.add(requestId);
800
+ }
394
801
  }
395
- return defaultCampaignMapper(data, match);
396
- }
802
+ clearActive(captureGeneration) {
803
+ if (this.activeLifecycle?.captureGeneration === captureGeneration) {
804
+ this.sealLifecycle(this.activeLifecycle);
805
+ }
806
+ }
807
+ refine(recognitionId, marker, captureGeneration) {
808
+ if (!this.reporter.refinement) {
809
+ return;
810
+ }
811
+ const lifecycle = this.requestLifecycles.get(recognitionId);
812
+ const markerCode = marker.code;
813
+ if (typeof markerCode !== "string" || markerCode.length === 0) {
814
+ this.settleRequest(recognitionId);
815
+ return;
816
+ }
817
+ if (!lifecycle || lifecycle.captureGeneration !== captureGeneration || lifecycle.markerCode !== null || !this.callbacks.isCurrentCapture(captureGeneration)) {
818
+ if (!lifecycle && this.registeredRequests.has(recognitionId) && typeof markerCode === "string" && markerCode.length > 0 && this.callbacks.isCurrentCapture(captureGeneration)) {
819
+ this.pendingMarkers.set(recognitionId, marker);
820
+ }
821
+ return;
822
+ }
823
+ if (!this.canAcceptMarker(lifecycle)) {
824
+ return;
825
+ }
826
+ this.claimMarker(lifecycle, marker);
827
+ }
828
+ reset() {
829
+ this.generation += 1;
830
+ for (const lifecycle of this.lifecycles.values()) {
831
+ if (lifecycle.refinementTimer) {
832
+ clearTimeout(lifecycle.refinementTimer);
833
+ }
834
+ if (lifecycle.continuityTimer) {
835
+ clearTimeout(lifecycle.continuityTimer);
836
+ }
837
+ }
838
+ this.lifecycles.clear();
839
+ this.requestLifecycles.clear();
840
+ this.pendingMarkers.clear();
841
+ this.registeredRequests.clear();
842
+ this.settledRequests.clear();
843
+ this.activeLifecycle = null;
844
+ }
845
+ startRefinement(lifecycle) {
846
+ if (lifecycle.refinementStarted || lifecycle.refinementClosed || !lifecycle.activityId || !lifecycle.markerMatch) {
847
+ return;
848
+ }
849
+ lifecycle.refinementStarted = true;
850
+ lifecycle.refinementInFlight = true;
851
+ void this.runRefinement(lifecycle).finally(() => {
852
+ lifecycle.refinementInFlight = false;
853
+ this.maybeDeleteSealed(lifecycle);
854
+ });
855
+ }
856
+ failMissingActivityId(lifecycle) {
857
+ if (!this.isRefinementActive(lifecycle)) {
858
+ return;
859
+ }
860
+ this.closeRefinement(lifecycle);
861
+ this.callbacks.emitError(
862
+ "activity-refinement",
863
+ new Error("Activity refinement requires a non-empty activity_id from the create response")
864
+ );
865
+ }
866
+ async runRefinement(lifecycle) {
867
+ const refinement = this.reporter.refinement;
868
+ if (!refinement || !lifecycle.activityId || !lifecycle.markerMatch) {
869
+ this.closeRefinement(lifecycle);
870
+ return;
871
+ }
872
+ const maxAttempts = Math.min(
873
+ MAX_REFINEMENT_ATTEMPTS,
874
+ Math.max(
875
+ 1,
876
+ Number.isSafeInteger(refinement.maxAttempts) ? refinement.maxAttempts ?? DEFAULT_MAX_REFINEMENT_ATTEMPTS : DEFAULT_MAX_REFINEMENT_ATTEMPTS
877
+ )
878
+ );
879
+ for (let attempt = 1; attempt <= maxAttempts; attempt += 1) {
880
+ if (!this.isRefinementActive(lifecycle)) {
881
+ return;
882
+ }
883
+ const now = this.now();
884
+ if (now > lifecycle.deadline) {
885
+ this.closeRefinement(lifecycle);
886
+ return;
887
+ }
888
+ try {
889
+ const result = await refineMatchedMaterialActivity(
890
+ this.reporter,
891
+ lifecycle.markerMatch,
892
+ {
893
+ operation: "refine",
894
+ activityId: lifecycle.activityId,
895
+ attempt,
896
+ deadline: lifecycle.deadline
897
+ },
898
+ () => this.isRefinementActive(lifecycle)
899
+ );
900
+ if (!this.isRefinementActive(lifecycle)) {
901
+ return;
902
+ }
903
+ this.closeRefinement(lifecycle);
904
+ if (result.campaign) {
905
+ this.callbacks.emitCampaign(result.campaign);
906
+ }
907
+ return;
908
+ } catch (caught) {
909
+ if (caught instanceof ActivityReportingCancelledError) {
910
+ return;
911
+ }
912
+ if (!this.isRefinementActive(lifecycle)) {
913
+ return;
914
+ }
915
+ const error = normalizeError(caught);
916
+ const { status, detail } = statusAndDetail(error);
917
+ const failureContext = {
918
+ operation: "refine",
919
+ activityId: lifecycle.activityId,
920
+ attempt,
921
+ deadline: lifecycle.deadline,
922
+ error,
923
+ status,
924
+ detail
925
+ };
926
+ let retry = false;
927
+ try {
928
+ retry = !isStableTerminalStatus(status) && attempt < maxAttempts && (refinement.now?.() ?? Date.now()) <= lifecycle.deadline && (refinement.shouldRetry ? await refinement.shouldRetry(failureContext) : defaultShouldRetry(failureContext));
929
+ } catch (policyError) {
930
+ if (!this.isRefinementActive(lifecycle)) {
931
+ return;
932
+ }
933
+ this.closeRefinement(lifecycle);
934
+ this.callbacks.emitError("activity-refinement", policyError);
935
+ return;
936
+ }
937
+ if (!this.isRefinementActive(lifecycle)) {
938
+ return;
939
+ }
940
+ if (retry) {
941
+ continue;
942
+ }
943
+ this.closeRefinement(lifecycle);
944
+ this.callbacks.emitError("activity-refinement", error);
945
+ return;
946
+ }
947
+ }
948
+ }
949
+ isTransportActive(lifecycle) {
950
+ return this.lifecycles.get(lifecycle.recognitionId) === lifecycle && this.callbacks.isCurrentCapture(lifecycle.captureGeneration);
951
+ }
952
+ isRefinementActive(lifecycle) {
953
+ if (!this.isTransportActive(lifecycle) || lifecycle.refinementClosed) {
954
+ return false;
955
+ }
956
+ const now = this.now();
957
+ if (now > lifecycle.deadline) {
958
+ this.closeRefinement(lifecycle);
959
+ return false;
960
+ }
961
+ return true;
962
+ }
963
+ canAcceptMarker(lifecycle) {
964
+ return lifecycle.markerCode === null && this.isRefinementActive(lifecycle);
965
+ }
966
+ claimMarker(lifecycle, marker) {
967
+ const markerCode = marker.code;
968
+ if (typeof markerCode !== "string" || markerCode.length === 0 || !this.canAcceptMarker(lifecycle)) {
969
+ return;
970
+ }
971
+ lifecycle.markerCode = markerCode;
972
+ lifecycle.markerMatch = { ...lifecycle.match, audioMarker: marker };
973
+ if (lifecycle.activityId) {
974
+ this.startRefinement(lifecycle);
975
+ } else if (lifecycle.createSettled) {
976
+ this.failMissingActivityId(lifecycle);
977
+ }
978
+ }
979
+ isContinuousMatch(lifecycle, match, captureGeneration, now) {
980
+ return !lifecycle.sealed && this.activeLifecycle === lifecycle && lifecycle.captureGeneration === captureGeneration && lifecycle.match.name === match.name && lifecycle.match.afpType === match.afpType && this.isTransportActive(lifecycle) && now - lifecycle.lastObservedAt <= ACTIVITY_CONTINUITY_GAP_MS;
981
+ }
982
+ renewContinuity(lifecycle, now) {
983
+ lifecycle.lastObservedAt = now;
984
+ this.scheduleContinuityExpiry(lifecycle);
985
+ }
986
+ scheduleContinuityExpiry(lifecycle) {
987
+ if (lifecycle.continuityTimer) {
988
+ clearTimeout(lifecycle.continuityTimer);
989
+ }
990
+ const remainingMs = lifecycle.lastObservedAt + ACTIVITY_CONTINUITY_GAP_MS - this.now();
991
+ lifecycle.continuityTimer = setTimeout(() => {
992
+ lifecycle.continuityTimer = null;
993
+ if (this.activeLifecycle !== lifecycle || lifecycle.sealed) {
994
+ return;
995
+ }
996
+ const now = this.now();
997
+ if (now - lifecycle.lastObservedAt <= ACTIVITY_CONTINUITY_GAP_MS) {
998
+ this.scheduleContinuityExpiry(lifecycle);
999
+ return;
1000
+ }
1001
+ this.sealLifecycle(lifecycle);
1002
+ }, Math.max(1, remainingMs + 1));
1003
+ }
1004
+ sealLifecycle(lifecycle) {
1005
+ if (lifecycle.sealed) {
1006
+ return;
1007
+ }
1008
+ lifecycle.sealed = true;
1009
+ if (lifecycle.continuityTimer) {
1010
+ clearTimeout(lifecycle.continuityTimer);
1011
+ lifecycle.continuityTimer = null;
1012
+ }
1013
+ if (this.activeLifecycle === lifecycle) {
1014
+ this.activeLifecycle = null;
1015
+ }
1016
+ this.removeRequestAssociations(lifecycle);
1017
+ this.maybeDeleteSealed(lifecycle);
1018
+ }
1019
+ closeRefinement(lifecycle) {
1020
+ if (lifecycle.refinementClosed) {
1021
+ return;
1022
+ }
1023
+ lifecycle.refinementClosed = true;
1024
+ if (lifecycle.refinementTimer) {
1025
+ clearTimeout(lifecycle.refinementTimer);
1026
+ lifecycle.refinementTimer = null;
1027
+ }
1028
+ this.removeRequestAssociations(lifecycle);
1029
+ this.maybeDeleteSealed(lifecycle);
1030
+ }
1031
+ removeRequestAssociations(lifecycle) {
1032
+ for (const [requestId, requestLifecycle] of this.requestLifecycles) {
1033
+ if (requestLifecycle === lifecycle) {
1034
+ this.requestLifecycles.delete(requestId);
1035
+ this.pendingMarkers.delete(requestId);
1036
+ this.registeredRequests.delete(requestId);
1037
+ }
1038
+ }
1039
+ }
1040
+ maybeDeleteSealed(lifecycle) {
1041
+ if (lifecycle.sealed && lifecycle.createSettled && !lifecycle.refinementInFlight) {
1042
+ this.deleteLifecycle(lifecycle);
1043
+ }
1044
+ }
1045
+ now() {
1046
+ return this.reporter.refinement && this.reporter.refinement.now ? this.reporter.refinement.now() : Date.now();
1047
+ }
1048
+ deleteLifecycle(lifecycle) {
1049
+ if (lifecycle.refinementTimer) {
1050
+ clearTimeout(lifecycle.refinementTimer);
1051
+ lifecycle.refinementTimer = null;
1052
+ }
1053
+ if (lifecycle.continuityTimer) {
1054
+ clearTimeout(lifecycle.continuityTimer);
1055
+ lifecycle.continuityTimer = null;
1056
+ }
1057
+ if (this.lifecycles.get(lifecycle.recognitionId) === lifecycle) {
1058
+ this.lifecycles.delete(lifecycle.recognitionId);
1059
+ }
1060
+ this.removeRequestAssociations(lifecycle);
1061
+ if (this.activeLifecycle === lifecycle) {
1062
+ this.activeLifecycle = null;
1063
+ }
1064
+ }
1065
+ };
397
1066
 
398
1067
  // src/microphone.ts
399
1068
  var WORKLET_PROCESSOR_NAME = "sori-microphone-capture";
@@ -518,11 +1187,11 @@ var defaultAudioGraphFactory = async (options) => {
518
1187
  };
519
1188
 
520
1189
  // src/microphone-matcher.ts
521
- function normalizeError(error) {
1190
+ function normalizeError2(error) {
522
1191
  return error instanceof Error ? error : new Error(String(error));
523
1192
  }
524
1193
  function emitError(emitter, phase, error) {
525
- const normalized = normalizeError(error);
1194
+ const normalized = normalizeError2(error);
526
1195
  emitter.emit("error", { phase, error: normalized });
527
1196
  return normalized;
528
1197
  }
@@ -540,6 +1209,7 @@ var MicrophoneMatcher = class {
540
1209
  captureSampleRate;
541
1210
  audioGraphFactory;
542
1211
  packSource;
1212
+ activityRefinement;
543
1213
  options;
544
1214
  extractor = null;
545
1215
  graphController = null;
@@ -558,6 +1228,7 @@ var MicrophoneMatcher = class {
558
1228
  latestAudioMarker = null;
559
1229
  latestAudioMarkerDetection = null;
560
1230
  audioMarkerRequestId = 0;
1231
+ activityRecognitionId = 0;
561
1232
  hopSize = 0;
562
1233
  diagnostics = {
563
1234
  sampleCallbacks: 0,
@@ -579,6 +1250,11 @@ var MicrophoneMatcher = class {
579
1250
  this.suspendContextOnStop = options.suspendContextOnStop ?? true;
580
1251
  this.audioGraphFactory = defaultAudioGraphFactory;
581
1252
  this.packSource = options.packSource;
1253
+ this.activityRefinement = options.activityReporter ? new ActivityRefinementCoordinator(options.activityReporter, {
1254
+ isCurrentCapture: (captureGeneration) => captureGeneration === this.captureGeneration && this.running,
1255
+ emitCampaign: (campaign) => this.events.emit("campaign", campaign),
1256
+ emitError: (phase, error) => emitError(this.events, phase, error)
1257
+ }) : null;
582
1258
  }
583
1259
  on(eventName, listener) {
584
1260
  this.events.on(eventName, listener);
@@ -662,6 +1338,7 @@ var MicrophoneMatcher = class {
662
1338
  try {
663
1339
  this.ensureNotDestroyed();
664
1340
  await this.prepare();
1341
+ this.activityRefinement?.reset();
665
1342
  this.matchWindowOrThrow().reset();
666
1343
  await this.matcherSession.clear();
667
1344
  } catch (error) {
@@ -799,6 +1476,8 @@ var MicrophoneMatcher = class {
799
1476
  this.diagnostics.readyQueries += 1;
800
1477
  let matchedBest = null;
801
1478
  let shouldEmitNoMatch = false;
1479
+ let audioMarkerRequestId = null;
1480
+ let associatedActivity = false;
802
1481
  const handleMatch = (event) => {
803
1482
  if (!event.best) {
804
1483
  return;
@@ -815,8 +1494,23 @@ var MicrophoneMatcher = class {
815
1494
  try {
816
1495
  this.diagnostics.matchRequests += 1;
817
1496
  const bestMatchPromise = this.matcherSession.bestMatch(query, this.matchConfig());
818
- const audioMarkerRequestId = this.beginAudioMarkerDetection(captureGeneration);
819
- await bestMatchPromise;
1497
+ audioMarkerRequestId = this.beginAudioMarkerDetection(captureGeneration);
1498
+ let observedBest;
1499
+ try {
1500
+ observedBest = await bestMatchPromise;
1501
+ } catch (error) {
1502
+ if (audioMarkerRequestId !== null) {
1503
+ this.activityRefinement?.discardRequest(audioMarkerRequestId);
1504
+ }
1505
+ throw error;
1506
+ }
1507
+ if (audioMarkerRequestId !== null) {
1508
+ associatedActivity = this.activityRefinement?.associate(
1509
+ audioMarkerRequestId,
1510
+ observedBest,
1511
+ captureGeneration
1512
+ ) ?? false;
1513
+ }
820
1514
  const distinctBest = this.withAudioMarkerForRequest(matchedBest, audioMarkerRequestId);
821
1515
  matchedBest = distinctBest;
822
1516
  } finally {
@@ -828,9 +1522,21 @@ var MicrophoneMatcher = class {
828
1522
  }
829
1523
  if (matchedBest) {
830
1524
  this.events.emit("match", { best: matchedBest });
831
- void this.reportCampaign(matchedBest, captureGeneration);
1525
+ this.activityRecognitionId += 1;
1526
+ this.reportCampaign(
1527
+ this.activityRecognitionId,
1528
+ matchedBest,
1529
+ captureGeneration,
1530
+ audioMarkerRequestId
1531
+ );
832
1532
  } else if (shouldEmitNoMatch) {
1533
+ if (audioMarkerRequestId !== null && !associatedActivity) {
1534
+ this.activityRefinement?.discardRequest(audioMarkerRequestId);
1535
+ }
1536
+ this.activityRefinement?.clearActive(captureGeneration);
833
1537
  this.events.emit("nomatch", {});
1538
+ } else if (audioMarkerRequestId !== null && !associatedActivity) {
1539
+ this.activityRefinement?.discardRequest(audioMarkerRequestId);
834
1540
  }
835
1541
  } while (this.pendingMatch || this.matchWindowOrThrow().hasReadyQuery());
836
1542
  } catch (error) {
@@ -850,6 +1556,7 @@ var MicrophoneMatcher = class {
850
1556
  this.latestAudioMarker = null;
851
1557
  this.latestAudioMarkerDetection = null;
852
1558
  this.audioMarkerRequestId += 1;
1559
+ this.activityRefinement?.reset();
853
1560
  this.matchWindowOrThrow().reset();
854
1561
  await this.extractor?.reset();
855
1562
  }
@@ -867,6 +1574,7 @@ var MicrophoneMatcher = class {
867
1574
  }
868
1575
  const requestId = this.audioMarkerRequestId + 1;
869
1576
  this.audioMarkerRequestId = requestId;
1577
+ this.activityRefinement?.registerRequest(requestId);
870
1578
  const markerPcm = float32ToPcm16Le(this.markerPcmBuffer);
871
1579
  void this.detectAudioMarkerForRequest(captureGeneration, requestId, markerPcm);
872
1580
  return requestId;
@@ -882,8 +1590,10 @@ var MicrophoneMatcher = class {
882
1590
  }
883
1591
  this.latestAudioMarkerDetection = { requestId, detection };
884
1592
  this.emitAudioMarkerIfChanged(detection);
1593
+ this.activityRefinement?.refine(requestId, detection, captureGeneration);
885
1594
  return detection;
886
1595
  } catch (error) {
1596
+ this.activityRefinement?.settleRequest(requestId);
887
1597
  emitError(this.events, "audiomarker", error);
888
1598
  return null;
889
1599
  }
@@ -976,19 +1686,17 @@ var MicrophoneMatcher = class {
976
1686
  }
977
1687
  return this.matchWindow;
978
1688
  }
979
- async reportCampaign(match, captureGeneration) {
1689
+ reportCampaign(recognitionId, match, captureGeneration, audioMarkerRequestId) {
980
1690
  const reporter = this.options.activityReporter;
981
1691
  if (!reporter) {
982
1692
  return;
983
1693
  }
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
- }
1694
+ this.activityRefinement?.report(
1695
+ recognitionId,
1696
+ match,
1697
+ captureGeneration,
1698
+ audioMarkerRequestId
1699
+ );
992
1700
  }
993
1701
  };
994
1702
  function appendFloat32(left, right) {
@@ -1073,6 +1781,8 @@ function createLocalStorageSessionManager(options) {
1073
1781
 
1074
1782
  // src/audio-recognizer.ts
1075
1783
  var DEFAULT_SORI_API_ENDPOINT = "https://console.soriapi.com/api";
1784
+ var MAX_API_ENDPOINT_LENGTH = 32 * 1024;
1785
+ var ASCII_SLASH_CODE_UNIT = 47;
1076
1786
  var FORWARDED_EVENTS = [
1077
1787
  "ready",
1078
1788
  "microphoneready",
@@ -1100,12 +1810,24 @@ function ensureNonEmptyString(name, value) {
1100
1810
  }
1101
1811
  return value;
1102
1812
  }
1103
- function normalizeEndpointPrefix(endpoint) {
1104
- const value = String(endpoint).replace(/\/+$/, "");
1813
+ function stringifyAndValidateApiEndpoint(endpoint) {
1814
+ const value = String(endpoint);
1815
+ if (value.length > MAX_API_ENDPOINT_LENGTH) {
1816
+ throw new Error(
1817
+ `AudioRecognizer apiEndpoint exceeds maximum length of ${MAX_API_ENDPOINT_LENGTH} UTF-16 code units`
1818
+ );
1819
+ }
1105
1820
  return value;
1106
1821
  }
1822
+ function normalizeValidatedEndpointPrefix(value) {
1823
+ let end = value.length;
1824
+ while (end > 0 && value.charCodeAt(end - 1) === ASCII_SLASH_CODE_UNIT) {
1825
+ end -= 1;
1826
+ }
1827
+ return end === value.length ? value : value.slice(0, end);
1828
+ }
1107
1829
  function joinEndpoint(prefix, path) {
1108
- return `${normalizeEndpointPrefix(prefix)}${path.startsWith("/") ? path : `/${path}`}`;
1830
+ return `${prefix}${path.startsWith("/") ? path : `/${path}`}`;
1109
1831
  }
1110
1832
  function createEphemeralKeyResolver(endpoint, fetcher, requestInit) {
1111
1833
  return async () => {
@@ -1133,8 +1855,15 @@ function resolveRecognizerOptions(options) {
1133
1855
  const activityReportingDisabled = options.activityReporter === false;
1134
1856
  const customActivityReporter = activityReportingDisabled ? void 0 : options.activityReporter;
1135
1857
  const appId = legacyAuth?.appId ?? options.appId;
1136
- const authEndpoint = legacyAuth?.endpoint ?? options.authEndpoint ?? joinEndpoint(apiEndpoint, "/auth");
1137
- const activityEndpoint = activityReportingDisabled ? void 0 : customActivityReporter?.endpoint ?? options.activityEndpoint ?? joinEndpoint(apiEndpoint, "/activity/");
1858
+ const configuredAuthEndpoint = legacyAuth?.endpoint ?? options.authEndpoint;
1859
+ const configuredActivityEndpoint = activityReportingDisabled ? void 0 : customActivityReporter?.endpoint ?? options.activityEndpoint;
1860
+ const derivesAuthEndpoint = configuredAuthEndpoint == null;
1861
+ const derivesActivityEndpoint = !activityReportingDisabled && configuredActivityEndpoint == null;
1862
+ const derivesApiEndpoint = derivesAuthEndpoint || derivesActivityEndpoint;
1863
+ const validatedApiEndpoint = options.apiEndpoint !== void 0 || derivesApiEndpoint ? stringifyAndValidateApiEndpoint(apiEndpoint) : void 0;
1864
+ const normalizedApiEndpoint = derivesApiEndpoint ? normalizeValidatedEndpointPrefix(validatedApiEndpoint) : void 0;
1865
+ const authEndpoint = configuredAuthEndpoint ?? joinEndpoint(normalizedApiEndpoint, "/auth");
1866
+ const activityEndpoint = activityReportingDisabled ? void 0 : configuredActivityEndpoint ?? joinEndpoint(normalizedApiEndpoint, "/activity/");
1138
1867
  const ephemeralKey = legacyAuth?.ephemeralKey ?? options.ephemeralKey ?? options.ephemeraKey ?? (legacyAuth?.ephemeralKeyEndpoint ?? options.ephemeralKeyEndpoint ? createEphemeralKeyResolver(
1139
1868
  legacyAuth?.ephemeralKeyEndpoint ?? options.ephemeralKeyEndpoint,
1140
1869
  legacyAuth?.ephemeralKeyFetch ?? options.ephemeralKeyFetch ?? legacyAuth?.fetch ?? sharedFetch,
@@ -1143,7 +1872,8 @@ function resolveRecognizerOptions(options) {
1143
1872
  const activityReporter = activityEndpoint === void 0 ? void 0 : {
1144
1873
  ...customActivityReporter,
1145
1874
  endpoint: activityEndpoint,
1146
- fetch: customActivityReporter?.fetch ?? sharedFetch
1875
+ fetch: customActivityReporter?.fetch ?? sharedFetch,
1876
+ refinement: customActivityReporter ? customActivityReporter.refinement : {}
1147
1877
  };
1148
1878
  return {
1149
1879
  ...options,
@@ -1316,10 +2046,12 @@ var AudioRecognizer = class {
1316
2046
  // src/index.ts
1317
2047
  import { AudioFingerprintType as AudioFingerprintType3 } from "@sorisdk/matcher";
1318
2048
  export {
2049
+ ACTIVITY_REFINEMENT_WINDOW_MS,
1319
2050
  AudioFingerprintType3 as AudioFingerprintType,
1320
2051
  AudioRecognizer,
1321
2052
  DEFAULT_BROWSER_AUDIOPACK_VERSION,
1322
2053
  DEFAULT_SORI_API_ENDPOINT,
2054
+ MAX_API_ENDPOINT_LENGTH,
1323
2055
  MicrophoneMatcher,
1324
2056
  authenticateAndLoadAudioPack,
1325
2057
  createLocalStorageAudioPackStore,