@sorisdk/web-audio 0.6.7 → 0.6.9

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
@@ -13,6 +13,36 @@ Public integration guide: https://docs.soriapi.com/ko/integration/web
13
13
  - An ephemeral key issued by your server
14
14
  - Microphone permission granted by the user
15
15
 
16
+ ## Unreleased: Console-resolved audio marker events
17
+
18
+ The public `audiomarker` event now contains only the marker identity managed in
19
+ SORI Console. The SDK emits it after a successful activity POST or refinement
20
+ PUT returns both a non-blank `audio_marker_id` and `audio_marker_name`:
21
+
22
+ ```ts
23
+ recognizer.on("audiomarker", ({ marker }) => {
24
+ console.log(marker.id, marker.name);
25
+ });
26
+ ```
27
+
28
+ The event is independent from campaign mapping and can be emitted when the
29
+ activity response has no campaign. Repeated responses within one continuous
30
+ material segment emit once; an intervening material, no-match, capture reset,
31
+ or expired continuity gap starts a new segment that may emit again.
32
+
33
+ Local WASM detection and labels such as `code_000` remain private inputs to the
34
+ activity request. They are no longer present on public `audiomarker` or `match`
35
+ events, `campaign.match`, the exported `DetectedMatch`, or the
36
+ `@sorisdk/web-audio` type exports. Custom request and campaign mappers receive
37
+ the public codebook-free match; the SDK adds its private `trait.marker` only to
38
+ the outgoing activity payload after request mapping.
39
+
40
+ This deliberately replaces the 0.6.7-and-earlier event shape. Migrate from
41
+ `event.marker` as a nullable string and `event.detection` as a raw detector
42
+ result to the non-null `event.marker.id` and `event.marker.name` fields above.
43
+ There is no legacy fallback: disabled activity reporting, failed transport, or
44
+ a response with a missing or malformed id/name pair emits no public marker.
45
+
16
46
  ## Standalone CDN
17
47
 
18
48
  For a browser application that does not use npm or a bundler, import the
@@ -22,7 +52,7 @@ configuration, or Node-based asset server is required:
22
52
  ```html
23
53
  <script type="module">
24
54
  import { AudioRecognizer } from
25
- "https://cdn.iplateia.com/web/sorisdk/v0.6.7/sori-web-audio.mjs";
55
+ "https://cdn.iplateia.com/web/sorisdk/v0.6.9/sori-web-audio.mjs";
26
56
 
27
57
  const recognizer = new AudioRecognizer({
28
58
  appId: "YOUR_APP_ID",
@@ -49,7 +79,7 @@ configuration, or Node-based asset server is required:
49
79
  ```
50
80
 
51
81
  Static URL imports are valid inside `<script type="module">`. Dynamic
52
- `await import("https://cdn.iplateia.com/web/sorisdk/v0.6.7/sori-web-audio.mjs")`
82
+ `await import("https://cdn.iplateia.com/web/sorisdk/v0.6.9/sori-web-audio.mjs")`
53
83
  is an optional alternative when the SDK should be loaded conditionally.
54
84
 
55
85
  Pin an exact version in production. Do not construct a mutable `latest` URL.
@@ -119,7 +149,7 @@ An import map can give the standalone URL the npm package name:
119
149
  <script type="importmap">
120
150
  {
121
151
  "imports": {
122
- "@sorisdk/web-audio": "https://cdn.iplateia.com/web/sorisdk/v0.6.7/sori-web-audio.mjs"
152
+ "@sorisdk/web-audio": "https://cdn.iplateia.com/web/sorisdk/v0.6.9/sori-web-audio.mjs"
123
153
  }
124
154
  }
125
155
  </script>
@@ -171,6 +201,10 @@ recognizer.on("campaign", (event) => {
171
201
  console.log(event.activityId, event.campaign);
172
202
  });
173
203
 
204
+ recognizer.on("audiomarker", ({ marker }) => {
205
+ console.log(marker.id, marker.name);
206
+ });
207
+
174
208
  recognizer.on("error", ({ error }) => {
175
209
  console.error(error);
176
210
  });
@@ -185,6 +219,22 @@ await recognizer.stop();
185
219
  await recognizer.destroy();
186
220
  ```
187
221
 
222
+ ## Continuous recognition
223
+
224
+ Continuous observations of one material emit one public `match`. An intervening
225
+ material (A → B → A), two consecutive fingerprint no-match results, or a gap
226
+ greater than 30 seconds since the last successful observation starts a new run.
227
+ The next successful A is delivered immediately. A single fingerprint miss or
228
+ marker miss does not split a run. Successful observations renew continuity even
229
+ when no public `match` event is emitted, in both marker modes.
230
+
231
+ The 30-second boundary also covers capture interruptions with no queries; it is
232
+ checked when querying resumes. Two consecutive misses can confirm a boundary
233
+ sooner. `nomatch` is emitted on an observed boundary, rather than by an idle
234
+ timer. Sequential stop/start, failed-start cleanup, and successful database
235
+ replacement or mutation clear recognition and capture state. Pending queries
236
+ from an older capture cannot restore suppression in the new capture.
237
+
188
238
  ## Activity refinement
189
239
 
190
240
  The default `AudioRecognizer` activity reporter represents one ordered,
@@ -200,13 +250,15 @@ server activity:
200
250
  3. A marker-free match with an active marker-scan request also includes
201
251
  top-level `refinement_expected: true`.
202
252
  4. The returned `activity_id` is retained for 30 seconds.
203
- 5. If the same recognition gains `audioMarker.code`, the SDK sends
253
+ 5. If the same recognition gains a private local marker detection, the SDK sends
204
254
  `PUT /api/activity/{activity_id}` with the same material and
205
255
  `trait.marker`.
206
- 6. The refined `campaign` event keeps the same non-empty `activityId`, so an
256
+ 6. A valid response marker id/name pair emits one public structured
257
+ `audiomarker` event, even when the response has no campaign.
258
+ 7. The refined `campaign` event keeps the same non-empty `activityId`, so an
207
259
  application can replace the earlier campaign row deterministically.
208
260
 
209
- A consecutive match for the same material and fingerprint type renews a local
261
+ A successful observation for the same material and fingerprint type renews a local
210
262
  30-second continuity lease without another POST or campaign event. That local
211
263
  lease can keep one segment alive beyond the initial server refinement window,
212
264
  but it never extends the original PUT deadline. A marker confirmed anywhere
@@ -228,10 +280,10 @@ refine the earlier A. Marker evidence arriving after a segment is sealed is
228
280
  ignored for that segment; create or PUT transport that already started may
229
281
  finish under the existing capture, deadline, and cancellation guards.
230
282
 
231
- Ordinary fingerprint `match` and marker-state `audiomarker` events remain
232
- independently observable; coalescing only changes activity mutation and
233
- campaign delivery. An `audiomarker` event alone does not mean that server
234
- activity refinement completed.
283
+ Fingerprint `match` events follow the continuous-run rules above and never
284
+ contain the private detector result. Public `audiomarker` delivery is sourced
285
+ only from a completed activity response. It is independent from campaign
286
+ availability but does prove that the server resolved the marker id and name.
235
287
 
236
288
  Recognition timing belongs only to the segment's first material POST. Later
237
289
  same-material observations update only the internal `lastObservedAt` lease;
@@ -273,7 +325,10 @@ Existing `mapMatchToRequest` and `mapResponseToCampaign` hooks continue to
273
325
  handle POST. Additive refinement hooks can override the PUT payload, response
274
326
  mapping, endpoint, headers, fetcher, and retry policy. The stable terminal
275
327
  statuses 404, 409, and 410 are never retried even if a custom policy returns
276
- `true`.
328
+ `true`. Match arguments passed to these hooks omit private detector state. When
329
+ local detection succeeds, the SDK injects its private marker into the outgoing
330
+ `trait.marker` after request mapping, while resolved public identity is parsed
331
+ directly from the unmodified activity response before campaign delivery.
277
332
 
278
333
  `recognized_at` and `position` are optional public request properties for
279
334
  source compatibility, but they are SDK-owned transport values. After a custom
@@ -404,7 +459,8 @@ fingerprint chunks and the existing match-window cadence are unchanged.
404
459
 
405
460
  1. Prepare your `appId` and ephemeral key flow.
406
461
  2. Create an `AudioRecognizer`.
407
- 3. Subscribe to the events you need, such as `campaign`, `match`, and `error`.
462
+ 3. Subscribe to the events you need, such as `campaign`, `audiomarker`, `match`,
463
+ and `error`.
408
464
  4. Call `start()` to begin recognition.
409
465
  5. Call `stop()` or `destroy()` when leaving the page or stopping capture.
410
466
 
Binary file
package/dist/index.d.ts CHANGED
@@ -1,5 +1,5 @@
1
- import { MatcherEphemeralAuthOptions, InitMatcherOptions, MatcherEphemeralAuthResult, AudioMarkerDetection, PackSource, MatchResult, MatcherSessionOptions, AudioFingerprintType } from '@sorisdk/matcher';
2
- export { AudioFingerprintType, AudioMarkerConfig, AudioMarkerDetection, PackSource } from '@sorisdk/matcher';
1
+ import { MatcherEphemeralAuthOptions, InitMatcherOptions, MatcherEphemeralAuthResult, PackSource, MatchResult, MatcherSessionOptions, AudioFingerprintType } from '@sorisdk/matcher';
2
+ export { AudioFingerprintType, AudioMarkerConfig, PackSource } from '@sorisdk/matcher';
3
3
  import { InitAfpgenOptions } from '@sorisdk/afpgen';
4
4
 
5
5
  declare const DEFAULT_BROWSER_AUDIOPACK_VERSION = "20220401000000";
@@ -53,11 +53,14 @@ interface InitMatchWindowWasmOptions {
53
53
  }
54
54
 
55
55
  interface DetectedMatch extends Pick<MatchResult, "afpType" | "durationMillis" | "name" | "position" | "score"> {
56
- audioMarker?: AudioMarkerDetection | null;
56
+ }
57
+ /** SORI Console-managed identity resolved from a successful activity response. */
58
+ interface AudioMarkerIdentity {
59
+ readonly id: string;
60
+ readonly name: string;
57
61
  }
58
62
  interface AudioMarkerEvent {
59
- marker: string | null;
60
- detection: AudioMarkerDetection;
63
+ readonly marker: AudioMarkerIdentity;
61
64
  }
62
65
  interface MaterialActivityRequestPayload {
63
66
  type: "material";
@@ -243,7 +246,6 @@ declare class MicrophoneMatcher {
243
246
  private markerPcmBuffer;
244
247
  private resampleBuffer;
245
248
  private resampleOffset;
246
- private latestAudioMarker;
247
249
  private latestAudioMarkerDetection;
248
250
  private audioMarkerRequestId;
249
251
  private activityRecognitionId;
@@ -281,7 +283,6 @@ declare class MicrophoneMatcher {
281
283
  private detectAudioMarkerForRequest;
282
284
  private matchConfig;
283
285
  private withAudioMarkerForRequest;
284
- private emitAudioMarkerIfChanged;
285
286
  private normalizeInputSamples;
286
287
  private resampleToTargetRate;
287
288
  private cleanupAfterStartFailure;
@@ -296,4 +297,4 @@ declare function requestMicrophoneStream(mediaDevices: MediaDevices, mediaConstr
296
297
  preferredSampleRate?: number;
297
298
  }): Promise<MediaStream>;
298
299
 
299
- 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, MAX_API_ENDPOINT_LENGTH, type MaterialActivityRequestPayload, MicrophoneMatcher, type MicrophoneMatcherActivityRefinementOptions, type MicrophoneMatcherActivityReporterOptions, type MicrophoneMatcherCampaignEvent, type MicrophoneMatcherEventMap, type MicrophoneMatcherEventName, type MicrophoneMatcherListener, authenticateAndLoadAudioPack, createLocalStorageAudioPackStore, createLocalStorageSessionManager, createMicrophoneMatcher, requestMicrophoneStream };
300
+ export { ACTIVITY_REFINEMENT_WINDOW_MS, type ActivityRefinementFailureContext, type ActivityRefinementRequestContext, type AudioMarkerEvent, type AudioMarkerIdentity, 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, MAX_API_ENDPOINT_LENGTH, 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
@@ -354,6 +354,15 @@ var FingerprintMatchWindow = class {
354
354
  }
355
355
  };
356
356
 
357
+ // src/activity-refinement.ts
358
+ import { MATCH_CONTINUITY_GAP_MS } from "@sorisdk/matcher";
359
+
360
+ // src/detected-match.ts
361
+ function toPublicDetectedMatch(match) {
362
+ const { audioMarker: _internalAudioMarker, ...publicMatch } = match;
363
+ return publicMatch;
364
+ }
365
+
357
366
  // src/activity.ts
358
367
  var ActivityReportingError = class extends Error {
359
368
  status;
@@ -417,7 +426,7 @@ function validateRequestPayload(payload) {
417
426
  }
418
427
  async function resolveCreateRequestPayload(reporter, match) {
419
428
  const payload = validateRequestPayload(
420
- reporter.mapMatchToRequest ? await reporter.mapMatchToRequest(match) : defaultRequestPayload(match)
429
+ reporter.mapMatchToRequest ? await reporter.mapMatchToRequest(toPublicDetectedMatch(match)) : defaultRequestPayload(match)
421
430
  );
422
431
  if (!payload) {
423
432
  return null;
@@ -428,7 +437,7 @@ async function resolveCreateRequestPayload(reporter, match) {
428
437
  refinement_expected: _ignoredRefinementExpected,
429
438
  ...requestPayload
430
439
  } = payload;
431
- return requestPayload;
440
+ return withInternalAudioMarker(requestPayload, match);
432
441
  }
433
442
  async function resolveRefineRequestPayload(reporter, match, context) {
434
443
  const refinement = reporter.refinement;
@@ -436,7 +445,7 @@ async function resolveRefineRequestPayload(reporter, match, context) {
436
445
  return null;
437
446
  }
438
447
  const payload = validateRequestPayload(
439
- refinement.mapMatchToRequest ? await refinement.mapMatchToRequest(match, context) : reporter.mapMatchToRequest ? await reporter.mapMatchToRequest(match) : defaultRequestPayload(match)
448
+ refinement.mapMatchToRequest ? await refinement.mapMatchToRequest(toPublicDetectedMatch(match), context) : reporter.mapMatchToRequest ? await reporter.mapMatchToRequest(toPublicDetectedMatch(match)) : defaultRequestPayload(match)
440
449
  );
441
450
  if (!payload) {
442
451
  return null;
@@ -447,7 +456,20 @@ async function resolveRefineRequestPayload(reporter, match, context) {
447
456
  refinement_expected: _ignoredRefinementExpected,
448
457
  ...requestPayload
449
458
  } = payload;
450
- return requestPayload;
459
+ return withInternalAudioMarker(requestPayload, match);
460
+ }
461
+ function withInternalAudioMarker(payload, match) {
462
+ const marker = match.audioMarker?.code;
463
+ if (typeof marker !== "string" || marker.length === 0) {
464
+ return payload;
465
+ }
466
+ return {
467
+ ...payload,
468
+ trait: {
469
+ ...payload.trait,
470
+ marker
471
+ }
472
+ };
451
473
  }
452
474
  function defaultCampaignMapper(payload, match) {
453
475
  const campaign = payload.campaign;
@@ -455,12 +477,23 @@ function defaultCampaignMapper(payload, match) {
455
477
  return null;
456
478
  }
457
479
  return {
458
- match,
480
+ match: toPublicDetectedMatch(match),
459
481
  campaign,
460
482
  activityId: typeof payload.activity_id === "string" ? payload.activity_id : null,
461
483
  raw: payload
462
484
  };
463
485
  }
486
+ function resolvedAudioMarker(payload) {
487
+ if (!payload) {
488
+ return null;
489
+ }
490
+ const id = payload.audio_marker_id;
491
+ const name = payload.audio_marker_name;
492
+ if (typeof id !== "string" || id.trim().length === 0 || typeof name !== "string" || name.trim().length === 0) {
493
+ return null;
494
+ }
495
+ return Object.freeze({ id, name });
496
+ }
464
497
  async function resolveHeaders(reporter, refinement = false) {
465
498
  const refinementHeaders = reporter.refinement && reporter.refinement.headers;
466
499
  const baseHeaders = reporter.headers ? typeof reporter.headers === "function" ? await reporter.headers() : reporter.headers : void 0;
@@ -518,17 +551,28 @@ async function mapCreateResponse(reporter, data, match, refinementExpected) {
518
551
  if (!data) {
519
552
  return {
520
553
  activityId: null,
554
+ audioMarker: null,
521
555
  campaign: null,
522
556
  creationAttempted: true,
523
557
  refinementExpected
524
558
  };
525
559
  }
526
- const campaign = reporter.mapResponseToCampaign ? await reporter.mapResponseToCampaign(data, match) : defaultCampaignMapper(data, match);
560
+ const audioMarker = resolvedAudioMarker(data);
561
+ let campaign;
562
+ let campaignMappingFailure;
563
+ try {
564
+ campaign = reporter.mapResponseToCampaign ? await reporter.mapResponseToCampaign(data, toPublicDetectedMatch(match)) : defaultCampaignMapper(data, match);
565
+ } catch (error) {
566
+ campaign = null;
567
+ campaignMappingFailure = { error };
568
+ }
527
569
  return {
528
570
  activityId: responseActivityId(data, campaign),
571
+ audioMarker,
529
572
  campaign,
530
573
  creationAttempted: true,
531
- refinementExpected
574
+ refinementExpected,
575
+ ...campaignMappingFailure ? { campaignMappingFailure } : {}
532
576
  };
533
577
  }
534
578
  async function resolveRefinementEndpoint(reporter, activityId) {
@@ -544,6 +588,7 @@ async function createMatchedMaterialActivity(reporter, match, refinementExpected
544
588
  if (!payload) {
545
589
  return {
546
590
  activityId: null,
591
+ audioMarker: null,
547
592
  campaign: null,
548
593
  creationAttempted: false,
549
594
  refinementExpected: false
@@ -576,11 +621,11 @@ async function createMatchedMaterialActivity(reporter, match, refinementExpected
576
621
  async function refineMatchedMaterialActivity(reporter, match, context, isActive = () => true) {
577
622
  const refinement = reporter.refinement;
578
623
  if (!refinement) {
579
- return { activityId: null, campaign: null };
624
+ return { activityId: null, audioMarker: null, campaign: null };
580
625
  }
581
626
  const payload = await resolveRefineRequestPayload(reporter, match, context);
582
627
  if (!payload) {
583
- return { activityId: null, campaign: null };
628
+ return { activityId: null, audioMarker: null, campaign: null };
584
629
  }
585
630
  const endpoint = await resolveRefinementEndpoint(reporter, context.activityId);
586
631
  const headers = await resolveHeaders(reporter, true);
@@ -596,8 +641,16 @@ async function refineMatchedMaterialActivity(reporter, match, context, isActive
596
641
  if (!data) {
597
642
  throw new ActivityReportingError("Activity refine failed: empty response", response.status);
598
643
  }
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);
644
+ const audioMarker = resolvedAudioMarker(data);
645
+ let mappedCampaign;
646
+ let campaignMappingFailure;
647
+ try {
648
+ mappedCampaign = refinement.mapResponseToCampaign ? await refinement.mapResponseToCampaign(data, toPublicDetectedMatch(match), context) : reporter.mapResponseToCampaign ? await reporter.mapResponseToCampaign(data, toPublicDetectedMatch(match)) : defaultCampaignMapper(data, match);
649
+ } catch (error) {
650
+ mappedCampaign = null;
651
+ campaignMappingFailure = { error };
652
+ }
653
+ const activityId = responseActivityId(data, mappedCampaign) ?? (campaignMappingFailure ? context.activityId : null);
601
654
  if (activityId !== context.activityId) {
602
655
  throw new ActivityReportingError(
603
656
  "Activity refine failed: response activity_id does not match the requested activity",
@@ -605,12 +658,16 @@ async function refineMatchedMaterialActivity(reporter, match, context, isActive
605
658
  );
606
659
  }
607
660
  const campaign = mappedCampaign ? { ...mappedCampaign, activityId: context.activityId } : null;
608
- return { activityId, campaign };
661
+ return {
662
+ activityId,
663
+ audioMarker,
664
+ campaign,
665
+ ...campaignMappingFailure ? { campaignMappingFailure } : {}
666
+ };
609
667
  }
610
668
 
611
669
  // src/activity-refinement.ts
612
670
  var ACTIVITY_REFINEMENT_WINDOW_MS = 3e4;
613
- var ACTIVITY_CONTINUITY_GAP_MS = 3e4;
614
671
  var DEFAULT_MAX_REFINEMENT_ATTEMPTS = 2;
615
672
  var MAX_REFINEMENT_ATTEMPTS = 10;
616
673
  function normalizeError(error) {
@@ -634,6 +691,7 @@ var ActivityRefinementCoordinator = class {
634
691
  registeredRequests = /* @__PURE__ */ new Set();
635
692
  settledRequests = /* @__PURE__ */ new Set();
636
693
  activeLifecycle = null;
694
+ activeMarkerSegment = null;
637
695
  generation = 0;
638
696
  constructor(reporter, callbacks) {
639
697
  this.reporter = reporter;
@@ -644,10 +702,14 @@ var ActivityRefinementCoordinator = class {
644
702
  this.registeredRequests.add(requestId);
645
703
  }
646
704
  }
647
- report(recognitionId, match, captureGeneration, requestId = null) {
705
+ report(recognitionId, match, captureGeneration, requestId = null, distinct = true) {
648
706
  const coordinatorGeneration = this.generation;
707
+ const markerSegment = this.resolveMarkerSegment(match, captureGeneration);
649
708
  const hasMarker = typeof match.audioMarker?.code === "string" && match.audioMarker.code.length > 0;
650
709
  const refinement = this.reporter.refinement;
710
+ if (!refinement && !distinct) {
711
+ return;
712
+ }
651
713
  const requestCanRefine = requestId !== null && !this.settledRequests.has(requestId);
652
714
  const pendingMarker = requestId === null ? void 0 : this.pendingMarkers.get(requestId);
653
715
  if (requestId !== null) {
@@ -690,6 +752,7 @@ var ActivityRefinementCoordinator = class {
690
752
  activityId: null,
691
753
  markerMatch: hasMarker ? match : null,
692
754
  markerCode: hasMarker ? match.audioMarker?.code ?? null : null,
755
+ markerSegment,
693
756
  refinementTimer: null,
694
757
  continuityTimer: null,
695
758
  createSettled: false,
@@ -722,9 +785,15 @@ var ActivityRefinementCoordinator = class {
722
785
  recognitionTiming
723
786
  ).then(
724
787
  (result) => {
788
+ if (coordinatorGeneration === this.generation && result.audioMarker && this.callbacks.isCurrentCapture(captureGeneration)) {
789
+ this.emitResolvedAudioMarker(markerSegment, result.audioMarker);
790
+ }
725
791
  if (coordinatorGeneration === this.generation && result.campaign && this.callbacks.isCurrentCapture(captureGeneration)) {
726
792
  this.callbacks.emitCampaign(result.campaign);
727
793
  }
794
+ if (coordinatorGeneration === this.generation && result.campaignMappingFailure && this.callbacks.isCurrentCapture(captureGeneration)) {
795
+ this.callbacks.emitError("activity", result.campaignMappingFailure.error);
796
+ }
728
797
  if (coordinatorGeneration !== this.generation || !lifecycle || this.lifecycles.get(recognitionId) !== lifecycle) {
729
798
  return;
730
799
  }
@@ -803,6 +872,9 @@ var ActivityRefinementCoordinator = class {
803
872
  if (this.activeLifecycle?.captureGeneration === captureGeneration) {
804
873
  this.sealLifecycle(this.activeLifecycle);
805
874
  }
875
+ if (this.activeMarkerSegment?.captureGeneration === captureGeneration) {
876
+ this.activeMarkerSegment = null;
877
+ }
806
878
  }
807
879
  refine(recognitionId, marker, captureGeneration) {
808
880
  if (!this.reporter.refinement) {
@@ -841,6 +913,7 @@ var ActivityRefinementCoordinator = class {
841
913
  this.registeredRequests.clear();
842
914
  this.settledRequests.clear();
843
915
  this.activeLifecycle = null;
916
+ this.activeMarkerSegment = null;
844
917
  }
845
918
  startRefinement(lifecycle) {
846
919
  if (lifecycle.refinementStarted || lifecycle.refinementClosed || !lifecycle.activityId || !lifecycle.markerMatch) {
@@ -901,9 +974,18 @@ var ActivityRefinementCoordinator = class {
901
974
  return;
902
975
  }
903
976
  this.closeRefinement(lifecycle);
977
+ if (result.audioMarker) {
978
+ this.emitResolvedAudioMarker(lifecycle.markerSegment, result.audioMarker);
979
+ }
904
980
  if (result.campaign) {
905
981
  this.callbacks.emitCampaign(result.campaign);
906
982
  }
983
+ if (result.campaignMappingFailure) {
984
+ this.callbacks.emitError(
985
+ "activity-refinement",
986
+ result.campaignMappingFailure.error
987
+ );
988
+ }
907
989
  return;
908
990
  } catch (caught) {
909
991
  if (caught instanceof ActivityReportingCancelledError) {
@@ -977,7 +1059,31 @@ var ActivityRefinementCoordinator = class {
977
1059
  }
978
1060
  }
979
1061
  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;
1062
+ 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 <= MATCH_CONTINUITY_GAP_MS;
1063
+ }
1064
+ resolveMarkerSegment(match, captureGeneration) {
1065
+ const now = this.now();
1066
+ const active = this.activeMarkerSegment;
1067
+ if (active && active.captureGeneration === captureGeneration && active.materialName === match.name && active.afpType === match.afpType && now - active.lastObservedAt <= MATCH_CONTINUITY_GAP_MS) {
1068
+ active.lastObservedAt = now;
1069
+ return active;
1070
+ }
1071
+ const segment = {
1072
+ captureGeneration,
1073
+ materialName: match.name,
1074
+ afpType: match.afpType,
1075
+ lastObservedAt: now,
1076
+ emitted: false
1077
+ };
1078
+ this.activeMarkerSegment = segment;
1079
+ return segment;
1080
+ }
1081
+ emitResolvedAudioMarker(segment, marker) {
1082
+ if (segment.emitted) {
1083
+ return;
1084
+ }
1085
+ segment.emitted = true;
1086
+ this.callbacks.emitAudioMarker(Object.freeze({ marker }));
981
1087
  }
982
1088
  renewContinuity(lifecycle, now) {
983
1089
  lifecycle.lastObservedAt = now;
@@ -987,14 +1093,14 @@ var ActivityRefinementCoordinator = class {
987
1093
  if (lifecycle.continuityTimer) {
988
1094
  clearTimeout(lifecycle.continuityTimer);
989
1095
  }
990
- const remainingMs = lifecycle.lastObservedAt + ACTIVITY_CONTINUITY_GAP_MS - this.now();
1096
+ const remainingMs = lifecycle.lastObservedAt + MATCH_CONTINUITY_GAP_MS - this.now();
991
1097
  lifecycle.continuityTimer = setTimeout(() => {
992
1098
  lifecycle.continuityTimer = null;
993
1099
  if (this.activeLifecycle !== lifecycle || lifecycle.sealed) {
994
1100
  return;
995
1101
  }
996
1102
  const now = this.now();
997
- if (now - lifecycle.lastObservedAt <= ACTIVITY_CONTINUITY_GAP_MS) {
1103
+ if (now - lifecycle.lastObservedAt <= MATCH_CONTINUITY_GAP_MS) {
998
1104
  this.scheduleContinuityExpiry(lifecycle);
999
1105
  return;
1000
1106
  }
@@ -1225,7 +1331,6 @@ var MicrophoneMatcher = class {
1225
1331
  markerPcmBuffer = new Float32Array(0);
1226
1332
  resampleBuffer = new Float32Array(0);
1227
1333
  resampleOffset = 0;
1228
- latestAudioMarker = null;
1229
1334
  latestAudioMarkerDetection = null;
1230
1335
  audioMarkerRequestId = 0;
1231
1336
  activityRecognitionId = 0;
@@ -1252,6 +1357,7 @@ var MicrophoneMatcher = class {
1252
1357
  this.packSource = options.packSource;
1253
1358
  this.activityRefinement = options.activityReporter ? new ActivityRefinementCoordinator(options.activityReporter, {
1254
1359
  isCurrentCapture: (captureGeneration) => captureGeneration === this.captureGeneration && this.running,
1360
+ emitAudioMarker: (event) => this.events.emit("audiomarker", event),
1255
1361
  emitCampaign: (campaign) => this.events.emit("campaign", campaign),
1256
1362
  emitError: (phase, error) => emitError(this.events, phase, error)
1257
1363
  }) : null;
@@ -1280,6 +1386,7 @@ var MicrophoneMatcher = class {
1280
1386
  }
1281
1387
  await this.matcherSession.loadPack(target);
1282
1388
  this.loadedPack = true;
1389
+ await this.resetCaptureState();
1283
1390
  } catch (error) {
1284
1391
  emitAndThrow(this.events, "loadPack", error);
1285
1392
  }
@@ -1321,6 +1428,7 @@ var MicrophoneMatcher = class {
1321
1428
  this.ensureNotDestroyed();
1322
1429
  await this.prepare();
1323
1430
  await this.matcherSession.addEntry(name, afpType, fingerprint);
1431
+ await this.resetCaptureState();
1324
1432
  } catch (error) {
1325
1433
  emitAndThrow(this.events, "addEntry", error);
1326
1434
  }
@@ -1330,6 +1438,7 @@ var MicrophoneMatcher = class {
1330
1438
  this.ensureNotDestroyed();
1331
1439
  await this.prepare();
1332
1440
  await this.matcherSession.addOrReplace(name, afpType, fingerprint);
1441
+ await this.resetCaptureState();
1333
1442
  } catch (error) {
1334
1443
  emitAndThrow(this.events, "addOrReplace", error);
1335
1444
  }
@@ -1338,9 +1447,8 @@ var MicrophoneMatcher = class {
1338
1447
  try {
1339
1448
  this.ensureNotDestroyed();
1340
1449
  await this.prepare();
1341
- this.activityRefinement?.reset();
1342
- this.matchWindowOrThrow().reset();
1343
1450
  await this.matcherSession.clear();
1451
+ await this.resetCaptureState();
1344
1452
  } catch (error) {
1345
1453
  emitAndThrow(this.events, "clear", error);
1346
1454
  }
@@ -1385,9 +1493,9 @@ var MicrophoneMatcher = class {
1385
1493
  return;
1386
1494
  }
1387
1495
  this.running = false;
1496
+ await this.resetCaptureState();
1388
1497
  await this.graphController?.stop();
1389
1498
  this.graphController = null;
1390
- await this.resetCaptureState();
1391
1499
  if (this.stopTracksOnStop) {
1392
1500
  this.stream?.getTracks().forEach((track) => track.stop());
1393
1501
  }
@@ -1477,15 +1585,18 @@ var MicrophoneMatcher = class {
1477
1585
  let matchedBest = null;
1478
1586
  let shouldEmitNoMatch = false;
1479
1587
  let audioMarkerRequestId = null;
1480
- let associatedActivity = false;
1588
+ let observedBest = null;
1481
1589
  const handleMatch = (event) => {
1482
- if (!event.best) {
1590
+ if (!event.best || captureGeneration !== this.captureGeneration || !this.running) {
1483
1591
  return;
1484
1592
  }
1485
1593
  matchedBest = event.best;
1486
1594
  shouldEmitNoMatch = false;
1487
1595
  };
1488
1596
  const handleNoMatch = () => {
1597
+ if (captureGeneration !== this.captureGeneration || !this.running) {
1598
+ return;
1599
+ }
1489
1600
  matchedBest = null;
1490
1601
  shouldEmitNoMatch = true;
1491
1602
  };
@@ -1495,7 +1606,6 @@ var MicrophoneMatcher = class {
1495
1606
  this.diagnostics.matchRequests += 1;
1496
1607
  const bestMatchPromise = this.matcherSession.bestMatch(query, this.matchConfig());
1497
1608
  audioMarkerRequestId = this.beginAudioMarkerDetection(captureGeneration);
1498
- let observedBest;
1499
1609
  try {
1500
1610
  observedBest = await bestMatchPromise;
1501
1611
  } catch (error) {
@@ -1504,15 +1614,10 @@ var MicrophoneMatcher = class {
1504
1614
  }
1505
1615
  throw error;
1506
1616
  }
1507
- if (audioMarkerRequestId !== null) {
1508
- associatedActivity = this.activityRefinement?.associate(
1509
- audioMarkerRequestId,
1510
- observedBest,
1511
- captureGeneration
1512
- ) ?? false;
1617
+ if (captureGeneration !== this.captureGeneration || !this.running) {
1618
+ return;
1513
1619
  }
1514
- const distinctBest = this.withAudioMarkerForRequest(matchedBest, audioMarkerRequestId);
1515
- matchedBest = distinctBest;
1620
+ observedBest = this.withAudioMarkerForRequest(observedBest, audioMarkerRequestId);
1516
1621
  } finally {
1517
1622
  this.matcherSession.off("match", handleMatch);
1518
1623
  this.matcherSession.off("nomatch", handleNoMatch);
@@ -1520,44 +1625,56 @@ var MicrophoneMatcher = class {
1520
1625
  if (captureGeneration !== this.captureGeneration || !this.running) {
1521
1626
  return;
1522
1627
  }
1523
- if (matchedBest) {
1524
- this.events.emit("match", { best: matchedBest });
1628
+ if (observedBest) {
1629
+ const distinct = matchedBest !== null;
1630
+ if (distinct) {
1631
+ this.activityRefinement?.clearActive(captureGeneration);
1632
+ this.events.emit("match", { best: toPublicDetectedMatch(observedBest) });
1633
+ }
1634
+ if (captureGeneration !== this.captureGeneration || !this.running) {
1635
+ return;
1636
+ }
1525
1637
  this.activityRecognitionId += 1;
1526
1638
  this.reportCampaign(
1527
1639
  this.activityRecognitionId,
1528
- matchedBest,
1640
+ observedBest,
1529
1641
  captureGeneration,
1530
- audioMarkerRequestId
1642
+ audioMarkerRequestId,
1643
+ distinct
1531
1644
  );
1532
- } else if (shouldEmitNoMatch) {
1533
- if (audioMarkerRequestId !== null && !associatedActivity) {
1645
+ } else {
1646
+ if (audioMarkerRequestId !== null) {
1534
1647
  this.activityRefinement?.discardRequest(audioMarkerRequestId);
1535
1648
  }
1536
- this.activityRefinement?.clearActive(captureGeneration);
1537
- this.events.emit("nomatch", {});
1538
- } else if (audioMarkerRequestId !== null && !associatedActivity) {
1539
- this.activityRefinement?.discardRequest(audioMarkerRequestId);
1649
+ if (shouldEmitNoMatch) {
1650
+ this.activityRefinement?.clearActive(captureGeneration);
1651
+ this.events.emit("nomatch", {});
1652
+ }
1540
1653
  }
1541
1654
  } while (this.pendingMatch || this.matchWindowOrThrow().hasReadyQuery());
1542
1655
  } catch (error) {
1543
- emitError(this.events, "match", error);
1656
+ if (captureGeneration === this.captureGeneration && this.running) {
1657
+ emitError(this.events, "match", error);
1658
+ }
1544
1659
  } finally {
1545
- this.matchInFlight = false;
1660
+ if (captureGeneration === this.captureGeneration) {
1661
+ this.matchInFlight = false;
1662
+ }
1546
1663
  }
1547
1664
  }
1548
1665
  async resetCaptureState() {
1549
1666
  this.captureGeneration += 1;
1667
+ this.matcherSession.resetRecognitionState();
1550
1668
  this.pendingMatch = false;
1551
1669
  this.matchInFlight = false;
1552
1670
  this.pcmBuffer = new Float32Array(0);
1553
1671
  this.markerPcmBuffer = new Float32Array(0);
1554
1672
  this.resampleBuffer = new Float32Array(0);
1555
1673
  this.resampleOffset = 0;
1556
- this.latestAudioMarker = null;
1557
1674
  this.latestAudioMarkerDetection = null;
1558
1675
  this.audioMarkerRequestId += 1;
1559
1676
  this.activityRefinement?.reset();
1560
- this.matchWindowOrThrow().reset();
1677
+ this.matchWindow?.reset();
1561
1678
  await this.extractor?.reset();
1562
1679
  }
1563
1680
  appendAudioMarkerSamples(samples) {
@@ -1589,12 +1706,13 @@ var MicrophoneMatcher = class {
1589
1706
  return null;
1590
1707
  }
1591
1708
  this.latestAudioMarkerDetection = { requestId, detection };
1592
- this.emitAudioMarkerIfChanged(detection);
1593
1709
  this.activityRefinement?.refine(requestId, detection, captureGeneration);
1594
1710
  return detection;
1595
1711
  } catch (error) {
1596
- this.activityRefinement?.settleRequest(requestId);
1597
- emitError(this.events, "audiomarker", error);
1712
+ if (captureGeneration === this.captureGeneration && this.running) {
1713
+ this.activityRefinement?.settleRequest(requestId);
1714
+ emitError(this.events, "audiomarker", error);
1715
+ }
1598
1716
  return null;
1599
1717
  }
1600
1718
  }
@@ -1616,14 +1734,6 @@ var MicrophoneMatcher = class {
1616
1734
  audioMarker: this.latestAudioMarkerDetection.detection
1617
1735
  };
1618
1736
  }
1619
- emitAudioMarkerIfChanged(detection) {
1620
- const marker = detection.code ?? null;
1621
- if (marker === this.latestAudioMarker) {
1622
- return;
1623
- }
1624
- this.latestAudioMarker = marker;
1625
- this.events.emit("audiomarker", { marker, detection });
1626
- }
1627
1737
  normalizeInputSamples(samples) {
1628
1738
  if (this.audioContext.sampleRate === TARGET_SAMPLE_RATE) {
1629
1739
  return samples;
@@ -1653,9 +1763,9 @@ var MicrophoneMatcher = class {
1653
1763
  }
1654
1764
  async cleanupAfterStartFailure() {
1655
1765
  this.running = false;
1766
+ await this.resetCaptureState();
1656
1767
  await this.graphController?.stop();
1657
1768
  this.graphController = null;
1658
- await this.resetCaptureState();
1659
1769
  if (this.stopTracksOnStop) {
1660
1770
  this.stream?.getTracks().forEach((track) => track.stop());
1661
1771
  }
@@ -1686,7 +1796,7 @@ var MicrophoneMatcher = class {
1686
1796
  }
1687
1797
  return this.matchWindow;
1688
1798
  }
1689
- reportCampaign(recognitionId, match, captureGeneration, audioMarkerRequestId) {
1799
+ reportCampaign(recognitionId, match, captureGeneration, audioMarkerRequestId, distinct) {
1690
1800
  const reporter = this.options.activityReporter;
1691
1801
  if (!reporter) {
1692
1802
  return;
@@ -1695,7 +1805,8 @@ var MicrophoneMatcher = class {
1695
1805
  recognitionId,
1696
1806
  match,
1697
1807
  captureGeneration,
1698
- audioMarkerRequestId
1808
+ audioMarkerRequestId,
1809
+ distinct
1699
1810
  );
1700
1811
  }
1701
1812
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sorisdk/web-audio",
3
- "version": "0.6.7",
3
+ "version": "0.6.9",
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.7",
38
- "@sorisdk/afpgen": "0.6.7"
37
+ "@sorisdk/afpgen": "0.6.9",
38
+ "@sorisdk/matcher": "0.6.9"
39
39
  },
40
40
  "devDependencies": {
41
41
  "tsup": "^8.5.1",