@iam-protocol/pulse-sdk 0.3.3 → 0.3.4
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 +1 -1
- package/src/pulse.ts +39 -5
- package/src/sensor/audio.ts +3 -1
- package/src/sensor/motion.ts +1 -1
- package/src/sensor/types.ts +4 -0
package/package.json
CHANGED
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
|
-
|
|
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
|
package/src/sensor/audio.ts
CHANGED
|
@@ -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[] = [];
|
package/src/sensor/motion.ts
CHANGED
|
@@ -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[] = [];
|
package/src/sensor/types.ts
CHANGED
|
@@ -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 */
|