@entros/pulse-sdk 2.0.0 → 3.0.0
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 +2 -2
- package/dist/index.js +109 -20
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +109 -20
- package/dist/index.mjs.map +1 -1
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
[](https://www.npmjs.com/package/@entros/pulse-sdk)
|
|
4
4
|
[](https://www.npmjs.com/package/@entros/pulse-sdk)
|
|
5
5
|
|
|
6
|
-
Client-side SDK for the Entros Protocol. Captures behavioral biometrics (voice, motion, touch), extracts a
|
|
6
|
+
Client-side SDK for the Entros Protocol. Captures behavioral biometrics (voice, motion, touch), extracts a 308-dimensional statistical feature vector (v3 expansion: MFCCs with pre-emphasis (C1-C12, MFCC[0] dropped), LPC coefficients, formant trajectories, voice quality, pitch contour shape, IMU FFT-band tremor, cross-axis covariance, mouse-derived FFT / autocorrelation analogues for desktop, touch curvature, gap distribution, path efficiency — see `docs/master/BLUEPRINT-feature-pipeline-v2.md`), generates a Groth16 zero-knowledge proof, and submits for on-chain verification on Solana. Raw biometric data stays on-device — only derived features and the proof are transmitted.
|
|
7
7
|
|
|
8
8
|
> **Looking for a drop-in?** Most integrators want [`@entros/verify`](https://github.com/entros-protocol/entros-verify) — a popup-pattern React component that wraps this SDK and ships verification in five lines of JSX. Use this package directly when you need to own the verification UX (custom capture canvas, branded loading states, mobile-native).
|
|
9
9
|
|
|
@@ -50,7 +50,7 @@ const result = await pulse.verify(touchElement);
|
|
|
50
50
|
## Pipeline
|
|
51
51
|
|
|
52
52
|
1. **Capture**: Audio (16kHz), IMU (accelerometer + gyroscope), touch (pressure + area) — event-driven, caller controls duration
|
|
53
|
-
2. **Extract**:
|
|
53
|
+
2. **Extract**: 308 features — speaker block (170): legacy F0 / jitter / shimmer / HNR / formant ratios / LTAS / amplitude (44) plus v3 additions: MFCCs + delta-MFCCs (72 — 12 used coefficients × 4 stats + 12 × 2 deltas, MFCC[0] dropped, pre-emphasis applied), LPC coefficient stats (24), formant absolute trajectories + bandwidths (16), voice quality CPP/tilt/H1-H2/sub-bands (9), pitch contour DCT (5). Motion block (81): legacy jerk + jounce per IMU axis (54) plus v2 additions: cross-axis covariance (6), FFT band energy on accel axes (12), physiological tremor peak (2), direction-reversal stats (3), motion-magnitude autocorrelation (4); desktop captures use mouse-derived analogues for these v2 additions. Touch block (57): legacy velocity + pressure dynamics (36) plus v2 additions: pressure derivative (4), contact aspect ratio + area derivative (4), trajectory curvature (3), velocity autocorrelation (3), inter-touch gap distribution (4), path efficiency + per-stroke length (3).
|
|
54
54
|
3. **Validate**: Feature summaries sent to Entros validation server for server-side analysis
|
|
55
55
|
4. **Hash**: SimHash → 256-bit Temporal Fingerprint → Poseidon commitment
|
|
56
56
|
5. **Prove**: Groth16 proof that new fingerprint is within Hamming distance of previous
|
package/dist/index.js
CHANGED
|
@@ -138,6 +138,25 @@ function sdkWarn(...args) {
|
|
|
138
138
|
|
|
139
139
|
// src/sensor/audio.ts
|
|
140
140
|
var TARGET_SAMPLE_RATE = 16e3;
|
|
141
|
+
var TARGET_CAPTURE_RMS = 0.05;
|
|
142
|
+
var MIN_RMS_FOR_NORMALIZATION = 1e-4;
|
|
143
|
+
var MAX_NORMALIZATION_GAIN = 50;
|
|
144
|
+
function normalizeCaptureRMS(samples) {
|
|
145
|
+
if (samples.length === 0) return samples;
|
|
146
|
+
let sumSq = 0;
|
|
147
|
+
for (let i = 0; i < samples.length; i++) {
|
|
148
|
+
const s = samples[i];
|
|
149
|
+
sumSq += s * s;
|
|
150
|
+
}
|
|
151
|
+
const rms = Math.sqrt(sumSq / samples.length);
|
|
152
|
+
if (rms < MIN_RMS_FOR_NORMALIZATION) return samples;
|
|
153
|
+
const gain = Math.min(TARGET_CAPTURE_RMS / rms, MAX_NORMALIZATION_GAIN);
|
|
154
|
+
const out = new Float32Array(samples.length);
|
|
155
|
+
for (let i = 0; i < samples.length; i++) {
|
|
156
|
+
out[i] = Math.max(-1, Math.min(1, samples[i] * gain));
|
|
157
|
+
}
|
|
158
|
+
return out;
|
|
159
|
+
}
|
|
141
160
|
async function captureAudio(options = {}) {
|
|
142
161
|
const {
|
|
143
162
|
signal,
|
|
@@ -225,8 +244,9 @@ async function captureAudio(options = {}) {
|
|
|
225
244
|
samples.set(chunk, offset);
|
|
226
245
|
offset += chunk.length;
|
|
227
246
|
}
|
|
247
|
+
const normalized = normalizeCaptureRMS(samples);
|
|
228
248
|
resolve({
|
|
229
|
-
samples,
|
|
249
|
+
samples: normalized,
|
|
230
250
|
sampleRate: capturedSampleRate,
|
|
231
251
|
duration: totalLength / capturedSampleRate
|
|
232
252
|
});
|
|
@@ -401,6 +421,9 @@ function variance(values, mu) {
|
|
|
401
421
|
for (const v of values) sum += (v - m) ** 2;
|
|
402
422
|
return sum / (values.length - 1);
|
|
403
423
|
}
|
|
424
|
+
var SKEWNESS_BOUND = 20;
|
|
425
|
+
var KURTOSIS_LOWER = 0;
|
|
426
|
+
var KURTOSIS_UPPER = 50;
|
|
404
427
|
function skewness(values) {
|
|
405
428
|
if (values.length < 3) return 0;
|
|
406
429
|
const n = values.length;
|
|
@@ -409,7 +432,8 @@ function skewness(values) {
|
|
|
409
432
|
if (s === 0) return 0;
|
|
410
433
|
let sum = 0;
|
|
411
434
|
for (const v of values) sum += ((v - m) / s) ** 3;
|
|
412
|
-
|
|
435
|
+
const raw = n / ((n - 1) * (n - 2)) * sum;
|
|
436
|
+
return Math.max(-SKEWNESS_BOUND, Math.min(SKEWNESS_BOUND, raw));
|
|
413
437
|
}
|
|
414
438
|
function kurtosis(values) {
|
|
415
439
|
if (values.length < 4) return 0;
|
|
@@ -420,7 +444,7 @@ function kurtosis(values) {
|
|
|
420
444
|
let sum = 0;
|
|
421
445
|
for (const v of values) sum += (v - m) ** 4 / s2 ** 2;
|
|
422
446
|
const k = n * (n + 1) / ((n - 1) * (n - 2) * (n - 3)) * sum - 3 * (n - 1) ** 2 / ((n - 2) * (n - 3));
|
|
423
|
-
return k;
|
|
447
|
+
return Math.max(KURTOSIS_LOWER, Math.min(KURTOSIS_UPPER, k));
|
|
424
448
|
}
|
|
425
449
|
function condense(values) {
|
|
426
450
|
const m = mean(values);
|
|
@@ -681,9 +705,20 @@ function extractLpcAnalysis(samples, sampleRate, frameSize, hopSize, lpcOrder =
|
|
|
681
705
|
|
|
682
706
|
// src/extraction/mfcc.ts
|
|
683
707
|
var NUM_MFCC_COEFFICIENTS = 13;
|
|
708
|
+
var MFCC_DROP_LEADING = 1;
|
|
709
|
+
var NUM_USED_MFCC = NUM_MFCC_COEFFICIENTS - MFCC_DROP_LEADING;
|
|
684
710
|
var DELTA_REGRESSION_HALF_WIDTH = 2;
|
|
685
|
-
var MFCC_FEATURE_COUNT =
|
|
686
|
-
|
|
711
|
+
var MFCC_FEATURE_COUNT = NUM_USED_MFCC * 4 + // mean, var, skew, kurt per coefficient
|
|
712
|
+
NUM_USED_MFCC * 2;
|
|
713
|
+
function applyPreEmphasis(samples) {
|
|
714
|
+
const out = new Float32Array(samples.length);
|
|
715
|
+
if (samples.length === 0) return out;
|
|
716
|
+
out[0] = samples[0];
|
|
717
|
+
for (let i = 1; i < samples.length; i++) {
|
|
718
|
+
out[i] = samples[i] - 0.97 * samples[i - 1];
|
|
719
|
+
}
|
|
720
|
+
return out;
|
|
721
|
+
}
|
|
687
722
|
function computeDelta(series, halfWidth) {
|
|
688
723
|
const n = series.length;
|
|
689
724
|
const out = new Array(n);
|
|
@@ -733,15 +768,16 @@ async function extractMfccFeatures(samples, sampleRate, frameSize, hopSize) {
|
|
|
733
768
|
return new Array(MFCC_FEATURE_COUNT).fill(0);
|
|
734
769
|
}
|
|
735
770
|
const mfccTracks = Array.from(
|
|
736
|
-
{ length:
|
|
771
|
+
{ length: NUM_USED_MFCC },
|
|
737
772
|
() => []
|
|
738
773
|
);
|
|
739
774
|
const frame = new Float32Array(frameSize);
|
|
740
775
|
Meyda.bufferSize = frameSize;
|
|
741
776
|
Meyda.sampleRate = sampleRate;
|
|
777
|
+
const emphasized = applyPreEmphasis(samples);
|
|
742
778
|
for (let i = 0; i < numFrames; i++) {
|
|
743
779
|
const start = i * hopSize;
|
|
744
|
-
frame.set(
|
|
780
|
+
frame.set(emphasized.subarray(start, start + frameSize), 0);
|
|
745
781
|
const result = Meyda.extract("mfcc", frame);
|
|
746
782
|
if (!Array.isArray(result) || result.length !== NUM_MFCC_COEFFICIENTS) {
|
|
747
783
|
continue;
|
|
@@ -754,21 +790,21 @@ async function extractMfccFeatures(samples, sampleRate, frameSize, hopSize) {
|
|
|
754
790
|
}
|
|
755
791
|
}
|
|
756
792
|
if (!allFinite) continue;
|
|
757
|
-
for (let c = 0; c <
|
|
758
|
-
mfccTracks[c].push(result[c]);
|
|
793
|
+
for (let c = 0; c < NUM_USED_MFCC; c++) {
|
|
794
|
+
mfccTracks[c].push(result[c + MFCC_DROP_LEADING]);
|
|
759
795
|
}
|
|
760
796
|
}
|
|
761
797
|
const out = [];
|
|
762
798
|
out.length = MFCC_FEATURE_COUNT;
|
|
763
799
|
let writeIdx = 0;
|
|
764
|
-
for (let c = 0; c <
|
|
800
|
+
for (let c = 0; c < NUM_USED_MFCC; c++) {
|
|
765
801
|
const stats = condense(mfccTracks[c]);
|
|
766
802
|
out[writeIdx++] = stats.mean;
|
|
767
803
|
out[writeIdx++] = stats.variance;
|
|
768
804
|
out[writeIdx++] = stats.skewness;
|
|
769
805
|
out[writeIdx++] = stats.kurtosis;
|
|
770
806
|
}
|
|
771
|
-
for (let c = 0; c <
|
|
807
|
+
for (let c = 0; c < NUM_USED_MFCC; c++) {
|
|
772
808
|
const delta = computeDelta(mfccTracks[c], DELTA_REGRESSION_HALF_WIDTH);
|
|
773
809
|
const muDelta = mean(delta);
|
|
774
810
|
out[writeIdx++] = muDelta;
|
|
@@ -1287,9 +1323,9 @@ async function extractSpeakerFeaturesDetailed(audio) {
|
|
|
1287
1323
|
for (let i = 0; i < numFrames; i++) {
|
|
1288
1324
|
const start = i * hopSize;
|
|
1289
1325
|
let sum = 0;
|
|
1290
|
-
const end = Math.min(start + frameSize,
|
|
1326
|
+
const end = Math.min(start + frameSize, normalizedSamples.length);
|
|
1291
1327
|
for (let j = start; j < end; j++) {
|
|
1292
|
-
sum += (
|
|
1328
|
+
sum += (normalizedSamples[j] ?? 0) * (normalizedSamples[j] ?? 0);
|
|
1293
1329
|
}
|
|
1294
1330
|
amplitudes.push(Math.sqrt(sum / (end - start)));
|
|
1295
1331
|
}
|
|
@@ -1398,15 +1434,15 @@ async function extractSpeakerFeaturesDetailed(audio) {
|
|
|
1398
1434
|
...ampFeatures,
|
|
1399
1435
|
// 5 [39..44] AMPLITUDE
|
|
1400
1436
|
...mfccFeatures,
|
|
1401
|
-
//
|
|
1437
|
+
// 72 [44..116] MFCC + delta-MFCC (MFCC[0] dropped)
|
|
1402
1438
|
...lpcStats,
|
|
1403
|
-
// 24 [
|
|
1439
|
+
// 24 [116..140] LPC coefficient stats
|
|
1404
1440
|
...formantTrajectoryFeatures,
|
|
1405
|
-
// 16 [
|
|
1441
|
+
// 16 [140..156] Formant absolutes + dynamics + bandwidths
|
|
1406
1442
|
...voiceQualityFeatures,
|
|
1407
|
-
// 9 [
|
|
1443
|
+
// 9 [156..165] Voice quality
|
|
1408
1444
|
...pitchShapeFeatures
|
|
1409
|
-
// 5 [
|
|
1445
|
+
// 5 [165..170] Pitch contour shape DCT
|
|
1410
1446
|
];
|
|
1411
1447
|
return { features, f0Contour: f0 };
|
|
1412
1448
|
}
|
|
@@ -1932,8 +1968,61 @@ function extractMouseDynamics(samples) {
|
|
|
1932
1968
|
angleAutoCorr[2] ?? 0,
|
|
1933
1969
|
normalizedPathLength
|
|
1934
1970
|
];
|
|
1935
|
-
const
|
|
1936
|
-
return
|
|
1971
|
+
const v2 = computeMouseV2(samples, vx, vy, accX, accY, speed, acc, jerk, directions);
|
|
1972
|
+
return [...legacyMouseDynamics, ...v2];
|
|
1973
|
+
}
|
|
1974
|
+
function computeMouseV2(samples, vx, vy, accX, accY, speed, acc, jerk, directions) {
|
|
1975
|
+
const out = [];
|
|
1976
|
+
const covPairs = [
|
|
1977
|
+
[vx, vy],
|
|
1978
|
+
[vx, accX],
|
|
1979
|
+
[vx, accY],
|
|
1980
|
+
[vy, accX],
|
|
1981
|
+
[vy, accY],
|
|
1982
|
+
[accX, accY]
|
|
1983
|
+
];
|
|
1984
|
+
for (const [a, b] of covPairs) out.push(covariance(a, b));
|
|
1985
|
+
const sampleRate = sampleRateFromTimestamps(samples.map((s) => s.timestamp));
|
|
1986
|
+
const fftSize = nextPow2(Math.max(64, speed.length));
|
|
1987
|
+
const bands = [
|
|
1988
|
+
[0, 2],
|
|
1989
|
+
[2, 6],
|
|
1990
|
+
[6, 12],
|
|
1991
|
+
[12, 30]
|
|
1992
|
+
];
|
|
1993
|
+
const speedSpectrum = realFFT(meanCenter(speed), fftSize);
|
|
1994
|
+
const accSpectrum = realFFT(meanCenter(acc), fftSize);
|
|
1995
|
+
const jerkSpectrum = realFFT(meanCenter(jerk), fftSize);
|
|
1996
|
+
for (const spectrum of [speedSpectrum, accSpectrum, jerkSpectrum]) {
|
|
1997
|
+
for (const [lo, hi] of bands) {
|
|
1998
|
+
out.push(bandEnergy(spectrum.real, spectrum.imag, sampleRate, lo, hi));
|
|
1999
|
+
}
|
|
2000
|
+
}
|
|
2001
|
+
const tremor = peakInBand(
|
|
2002
|
+
speedSpectrum.real,
|
|
2003
|
+
speedSpectrum.imag,
|
|
2004
|
+
sampleRate,
|
|
2005
|
+
4,
|
|
2006
|
+
12
|
|
2007
|
+
);
|
|
2008
|
+
out.push(tremor.freq, tremor.amplitude);
|
|
2009
|
+
const duration = captureDurationSec(samples);
|
|
2010
|
+
const reversalRates = [vx, vy, speed].map(
|
|
2011
|
+
(channel) => duration > 0 ? signChangeCount(derivative2(channel)) / duration : 0
|
|
2012
|
+
);
|
|
2013
|
+
out.push(mean(reversalRates), variance(reversalRates));
|
|
2014
|
+
let dirAccum = 0;
|
|
2015
|
+
for (let i = 1; i < directions.length; i++) {
|
|
2016
|
+
let diff = directions[i] - directions[i - 1];
|
|
2017
|
+
while (diff > Math.PI) diff -= 2 * Math.PI;
|
|
2018
|
+
while (diff < -Math.PI) diff += 2 * Math.PI;
|
|
2019
|
+
dirAccum += Math.abs(diff);
|
|
2020
|
+
}
|
|
2021
|
+
out.push(directions.length > 1 ? dirAccum / (directions.length - 1) : 0);
|
|
2022
|
+
for (const lag of [1, 5, 10, 25]) {
|
|
2023
|
+
out.push(autocorrelation(speed, lag));
|
|
2024
|
+
}
|
|
2025
|
+
return out.map((v) => Number.isFinite(v) ? v : 0);
|
|
1937
2026
|
}
|
|
1938
2027
|
|
|
1939
2028
|
// src/hashing/simhash.ts
|