@iam-protocol/pulse-sdk 0.3.3 → 0.3.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@iam-protocol/pulse-sdk",
3
- "version": "0.3.3",
3
+ "version": "0.3.5",
4
4
  "description": "Client-side SDK for IAM Protocol — sensor capture, TBH generation, ZK proof construction",
5
5
  "main": "dist/index.js",
6
6
  "module": "dist/index.mjs",
package/src/pulse.ts CHANGED
@@ -7,7 +7,7 @@ import type { VerificationResult } from "./submit/types";
7
7
  import type { StoredVerificationData } from "./identity/types";
8
8
 
9
9
  import { captureAudio } from "./sensor/audio";
10
- import { captureMotion } from "./sensor/motion";
10
+ import { captureMotion, requestMotionPermission } from "./sensor/motion";
11
11
  import { captureTouch } from "./sensor/touch";
12
12
  import { extractSpeakerFeatures, SPEAKER_FEATURE_COUNT } from "./extraction/speaker";
13
13
  import {
@@ -256,12 +256,30 @@ export class PulseSession {
256
256
  async startAudio(onAudioLevel?: (rms: number) => void): Promise<void> {
257
257
  if (this.audioStageState !== "idle")
258
258
  throw new Error("Audio capture already started");
259
+
260
+ // Acquire microphone permission within the user gesture context.
261
+ // Awaited so the caller knows audio is ready before proceeding.
262
+ // State transitions happen AFTER permission succeeds to avoid zombie state.
263
+ const stream = await navigator.mediaDevices.getUserMedia({
264
+ audio: {
265
+ sampleRate: 16000,
266
+ channelCount: 1,
267
+ echoCancellation: false,
268
+ noiseSuppression: false,
269
+ autoGainControl: false,
270
+ },
271
+ });
272
+
259
273
  this.audioStageState = "capturing";
260
274
  this.audioController = new AbortController();
261
275
  this.audioPromise = captureAudio({
262
276
  signal: this.audioController.signal,
263
277
  onAudioLevel,
264
- }).catch(() => null);
278
+ stream,
279
+ }).catch(() => {
280
+ stream.getTracks().forEach((t) => t.stop());
281
+ return null;
282
+ });
265
283
  }
266
284
 
267
285
  async stopAudio(): Promise<AudioCapture | null> {
@@ -281,10 +299,20 @@ export class PulseSession {
281
299
  async startMotion(): Promise<void> {
282
300
  if (this.motionStageState !== "idle")
283
301
  throw new Error("Motion capture already started");
302
+
303
+ // Request motion permission within the user gesture context (iOS 13+).
304
+ // Awaited so the capture timer doesn't start before the user approves.
305
+ const hasPermission = await requestMotionPermission();
306
+ if (!hasPermission) {
307
+ this.motionStageState = "skipped";
308
+ return;
309
+ }
310
+
284
311
  this.motionStageState = "capturing";
285
312
  this.motionController = new AbortController();
286
313
  this.motionPromise = captureMotion({
287
314
  signal: this.motionController.signal,
315
+ permissionGranted: true,
288
316
  }).catch(() => []);
289
317
  }
290
318
 
@@ -303,6 +331,10 @@ export class PulseSession {
303
331
  this.motionStageState = "skipped";
304
332
  }
305
333
 
334
+ isMotionCapturing(): boolean {
335
+ return this.motionStageState === "capturing";
336
+ }
337
+
306
338
  // --- Touch ---
307
339
 
308
340
  async startTouch(): Promise<void> {
@@ -412,16 +444,18 @@ export class PulseSDK {
412
444
  throw new Error(`Audio capture failed: ${err?.message ?? "microphone unavailable"}`);
413
445
  }
414
446
 
415
- // Motion
447
+ // Motion — startMotion auto-skips if permission denied (no throw)
416
448
  try {
417
449
  await session.startMotion();
450
+ } catch {
451
+ /* unexpected error — motion already skipped or idle */
452
+ }
453
+ if (session.isMotionCapturing()) {
418
454
  stopPromises.push(
419
455
  new Promise<void>((r) => setTimeout(r, DEFAULT_CAPTURE_MS))
420
456
  .then(() => session.stopMotion())
421
457
  .then(() => {})
422
458
  );
423
- } catch {
424
- session.skipMotion();
425
459
  }
426
460
 
427
461
  // Touch
@@ -20,9 +20,10 @@ export async function captureAudio(
20
20
  minDurationMs = MIN_CAPTURE_MS,
21
21
  maxDurationMs = MAX_CAPTURE_MS,
22
22
  onAudioLevel,
23
+ stream: preAcquiredStream,
23
24
  } = options;
24
25
 
25
- const stream = await navigator.mediaDevices.getUserMedia({
26
+ const stream = preAcquiredStream ?? await navigator.mediaDevices.getUserMedia({
26
27
  audio: {
27
28
  sampleRate: TARGET_SAMPLE_RATE,
28
29
  channelCount: 1,
@@ -33,6 +34,7 @@ export async function captureAudio(
33
34
  });
34
35
 
35
36
  const ctx = new AudioContext({ sampleRate: TARGET_SAMPLE_RATE });
37
+ await ctx.resume(); // Required on iOS — AudioContext may be suspended outside user gesture
36
38
  const capturedSampleRate = ctx.sampleRate;
37
39
  const source = ctx.createMediaStreamSource(stream);
38
40
  const chunks: Float32Array[] = [];
@@ -31,7 +31,7 @@ export async function captureMotion(
31
31
  maxDurationMs = MAX_CAPTURE_MS,
32
32
  } = options;
33
33
 
34
- const hasPermission = await requestMotionPermission();
34
+ const hasPermission = options.permissionGranted ?? await requestMotionPermission();
35
35
  if (!hasPermission) return [];
36
36
 
37
37
  const samples: MotionSample[] = [];
@@ -36,6 +36,10 @@ export interface CaptureOptions {
36
36
  maxDurationMs?: number;
37
37
  /** Called with RMS audio level (0-1) on each buffer during audio capture (~4x per second). */
38
38
  onAudioLevel?: (rms: number) => void;
39
+ /** Pre-acquired MediaStream. If provided, captureAudio skips getUserMedia. */
40
+ stream?: MediaStream;
41
+ /** If true, captureMotion skips requestMotionPermission (already acquired). */
42
+ permissionGranted?: boolean;
39
43
  }
40
44
 
41
45
  /** Stage of a capture session */