@svrnsec/pulse 0.7.0 → 0.9.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/LICENSE +21 -21
- package/README.md +883 -782
- package/SECURITY.md +27 -22
- package/bin/svrnsec-pulse.js +7 -7
- package/dist/{pulse.cjs.js → pulse.cjs} +6428 -6413
- package/dist/pulse.cjs.map +1 -0
- package/dist/pulse.esm.js +6429 -6415
- package/dist/pulse.esm.js.map +1 -1
- package/index.d.ts +949 -846
- package/package.json +189 -184
- package/pkg/pulse_core.js +174 -173
- package/src/analysis/audio.js +213 -213
- package/src/analysis/authenticityAudit.js +408 -393
- package/src/analysis/coherence.js +502 -502
- package/src/analysis/coordinatedBehavior.js +825 -804
- package/src/analysis/heuristic.js +428 -428
- package/src/analysis/jitter.js +446 -446
- package/src/analysis/llm.js +473 -472
- package/src/analysis/populationEntropy.js +404 -403
- package/src/analysis/provider.js +248 -248
- package/src/analysis/refraction.js +392 -391
- package/src/analysis/trustScore.js +356 -356
- package/src/cli/args.js +36 -36
- package/src/cli/commands/scan.js +192 -192
- package/src/cli/runner.js +157 -157
- package/src/collector/adaptive.js +200 -200
- package/src/collector/bio.js +297 -287
- package/src/collector/canvas.js +247 -239
- package/src/collector/dram.js +203 -203
- package/src/collector/enf.js +311 -311
- package/src/collector/entropy.js +195 -195
- package/src/collector/gpu.js +248 -245
- package/src/collector/idleAttestation.js +480 -480
- package/src/collector/sabTimer.js +189 -191
- package/src/errors.js +54 -0
- package/src/fingerprint.js +475 -475
- package/src/index.js +345 -342
- package/src/integrations/react-native.js +462 -459
- package/src/integrations/react.js +184 -185
- package/src/middleware/express.js +155 -155
- package/src/middleware/next.js +174 -175
- package/src/proof/challenge.js +249 -249
- package/src/proof/engagementToken.js +426 -394
- package/src/proof/fingerprint.js +268 -268
- package/src/proof/validator.js +82 -142
- package/src/registry/serializer.js +349 -349
- package/src/terminal.js +263 -263
- package/src/update-notifier.js +259 -264
- package/dist/pulse.cjs.js.map +0 -1
package/src/analysis/audio.js
CHANGED
|
@@ -1,213 +1,213 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* @svrnsec/pulse — AudioContext Oscillator Jitter
|
|
3
|
-
*
|
|
4
|
-
* Measures the scheduling jitter of the browser's audio pipeline.
|
|
5
|
-
* Real audio hardware callbacks are driven by a hardware interrupt (IRQ)
|
|
6
|
-
* from the sound card; the timing reflects the actual interrupt latency
|
|
7
|
-
* of the physical device. VM audio drivers (if present at all) are
|
|
8
|
-
* emulated and show either unrealistically low jitter or burst-mode
|
|
9
|
-
* scheduling artefacts that are statistically distinguishable.
|
|
10
|
-
*/
|
|
11
|
-
|
|
12
|
-
/**
|
|
13
|
-
* @param {object} [opts]
|
|
14
|
-
* @param {number} [opts.durationMs=2000] - how long to collect audio callbacks
|
|
15
|
-
* @param {number} [opts.bufferSize=256] - ScriptProcessorNode buffer size
|
|
16
|
-
* @returns {Promise<AudioJitter>}
|
|
17
|
-
*/
|
|
18
|
-
export async function collectAudioJitter(opts = {}) {
|
|
19
|
-
const { durationMs = 2000, bufferSize = 256 } = opts;
|
|
20
|
-
|
|
21
|
-
const base = {
|
|
22
|
-
available: false,
|
|
23
|
-
workletAvailable: false,
|
|
24
|
-
callbackJitterCV: 0,
|
|
25
|
-
noiseFloorMean: 0,
|
|
26
|
-
sampleRate: 0,
|
|
27
|
-
callbackCount: 0,
|
|
28
|
-
jitterTimings: [],
|
|
29
|
-
};
|
|
30
|
-
|
|
31
|
-
if (typeof AudioContext === 'undefined' && typeof webkitAudioContext === 'undefined') {
|
|
32
|
-
return base; // Node.js / server environment
|
|
33
|
-
}
|
|
34
|
-
|
|
35
|
-
let ctx;
|
|
36
|
-
try {
|
|
37
|
-
ctx = new (window.AudioContext || window.webkitAudioContext)();
|
|
38
|
-
} catch (_) {
|
|
39
|
-
return base;
|
|
40
|
-
}
|
|
41
|
-
|
|
42
|
-
// Some browsers require a user gesture before AudioContext can run.
|
|
43
|
-
if (ctx.state === 'suspended') {
|
|
44
|
-
try {
|
|
45
|
-
await ctx.resume();
|
|
46
|
-
} catch (_) {
|
|
47
|
-
await ctx.close().catch(() => {});
|
|
48
|
-
return base;
|
|
49
|
-
}
|
|
50
|
-
}
|
|
51
|
-
|
|
52
|
-
const sampleRate = ctx.sampleRate;
|
|
53
|
-
const expectedInterval = (bufferSize / sampleRate) * 1000; // ms per callback
|
|
54
|
-
|
|
55
|
-
const jitterTimings = []; // absolute AudioContext.currentTime at each callback
|
|
56
|
-
const callbackDeltas = [];
|
|
57
|
-
|
|
58
|
-
const result = await new Promise((resolve) => {
|
|
59
|
-
// ── AudioWorklet (preferred — runs on dedicated real-time thread) ──────
|
|
60
|
-
const useWorklet = typeof AudioWorkletNode !== 'undefined';
|
|
61
|
-
base.workletAvailable = useWorklet;
|
|
62
|
-
|
|
63
|
-
if (useWorklet) {
|
|
64
|
-
// Inline worklet: send currentTime back via MessagePort every buffer
|
|
65
|
-
const workletCode = `
|
|
66
|
-
class PulseProbe extends AudioWorkletProcessor {
|
|
67
|
-
process(inputs, outputs) {
|
|
68
|
-
this.port.postMessage({ t: currentTime });
|
|
69
|
-
// Pass-through silence
|
|
70
|
-
for (const out of outputs)
|
|
71
|
-
for (const ch of out) ch.fill(0);
|
|
72
|
-
return true;
|
|
73
|
-
}
|
|
74
|
-
}
|
|
75
|
-
registerProcessor('pulse-probe', PulseProbe);
|
|
76
|
-
`;
|
|
77
|
-
const blob = new Blob([workletCode], { type: 'application/javascript' });
|
|
78
|
-
const blobUrl = URL.createObjectURL(blob);
|
|
79
|
-
|
|
80
|
-
ctx.audioWorklet.addModule(blobUrl).then(() => {
|
|
81
|
-
const node = new AudioWorkletNode(ctx, 'pulse-probe');
|
|
82
|
-
node.port.onmessage = (e) => {
|
|
83
|
-
jitterTimings.push(e.data.t * 1000); // convert to ms
|
|
84
|
-
};
|
|
85
|
-
node.connect(ctx.destination);
|
|
86
|
-
|
|
87
|
-
setTimeout(async () => {
|
|
88
|
-
node.disconnect();
|
|
89
|
-
URL.revokeObjectURL(blobUrl);
|
|
90
|
-
resolve(node);
|
|
91
|
-
}, durationMs);
|
|
92
|
-
}).catch(() => {
|
|
93
|
-
URL.revokeObjectURL(blobUrl);
|
|
94
|
-
_fallbackScriptProcessor(ctx, bufferSize, durationMs, jitterTimings, resolve);
|
|
95
|
-
});
|
|
96
|
-
|
|
97
|
-
} else {
|
|
98
|
-
_fallbackScriptProcessor(ctx, bufferSize, durationMs, jitterTimings, resolve);
|
|
99
|
-
}
|
|
100
|
-
});
|
|
101
|
-
|
|
102
|
-
// ── Compute deltas between successive callback times ────────────────────
|
|
103
|
-
for (let i = 1; i < jitterTimings.length; i++) {
|
|
104
|
-
callbackDeltas.push(jitterTimings[i] - jitterTimings[i - 1]);
|
|
105
|
-
}
|
|
106
|
-
|
|
107
|
-
// ── Noise floor via AnalyserNode ─────────────────────────────────────────
|
|
108
|
-
// Feed a silent oscillator through an analyser; the FFT magnitude at silence
|
|
109
|
-
// reveals the hardware's thermal noise floor (varies per ADC/DAC chipset).
|
|
110
|
-
const noiseFloor = await _measureNoiseFloor(ctx);
|
|
111
|
-
|
|
112
|
-
await ctx.close().catch(() => {});
|
|
113
|
-
|
|
114
|
-
// ── Statistics ────────────────────────────────────────────────────────────
|
|
115
|
-
const mean = callbackDeltas.length
|
|
116
|
-
? callbackDeltas.reduce((s, v) => s + v, 0) / callbackDeltas.length
|
|
117
|
-
: 0;
|
|
118
|
-
const variance = callbackDeltas.length > 1
|
|
119
|
-
? callbackDeltas.reduce((s, v) => s + (v - mean) ** 2, 0) / (callbackDeltas.length - 1)
|
|
120
|
-
: 0;
|
|
121
|
-
const jitterCV = mean > 0 ? Math.sqrt(variance) / mean : 0;
|
|
122
|
-
|
|
123
|
-
return {
|
|
124
|
-
available: true,
|
|
125
|
-
workletAvailable: base.workletAvailable,
|
|
126
|
-
callbackJitterCV: jitterCV,
|
|
127
|
-
noiseFloorMean: noiseFloor.mean,
|
|
128
|
-
noiseFloorStd: noiseFloor.std,
|
|
129
|
-
sampleRate,
|
|
130
|
-
callbackCount: jitterTimings.length,
|
|
131
|
-
expectedIntervalMs: expectedInterval,
|
|
132
|
-
// Only include summary stats, not raw timings (privacy / size)
|
|
133
|
-
jitterMeanMs: mean,
|
|
134
|
-
jitterP95Ms: _percentile(callbackDeltas, 95),
|
|
135
|
-
};
|
|
136
|
-
}
|
|
137
|
-
|
|
138
|
-
/**
|
|
139
|
-
* @typedef {object} AudioJitter
|
|
140
|
-
* @property {boolean} available
|
|
141
|
-
* @property {boolean} workletAvailable
|
|
142
|
-
* @property {number} callbackJitterCV
|
|
143
|
-
* @property {number} noiseFloorMean
|
|
144
|
-
* @property {number} sampleRate
|
|
145
|
-
* @property {number} callbackCount
|
|
146
|
-
*/
|
|
147
|
-
|
|
148
|
-
// ---------------------------------------------------------------------------
|
|
149
|
-
// Internal helpers
|
|
150
|
-
// ---------------------------------------------------------------------------
|
|
151
|
-
|
|
152
|
-
function _fallbackScriptProcessor(ctx, bufferSize, durationMs, jitterTimings, resolve) {
|
|
153
|
-
// ScriptProcessorNode is deprecated but universally supported.
|
|
154
|
-
const proc = ctx.createScriptProcessor(bufferSize, 1, 1);
|
|
155
|
-
proc.onaudioprocess = () => {
|
|
156
|
-
jitterTimings.push(ctx.currentTime * 1000);
|
|
157
|
-
};
|
|
158
|
-
// Connect to keep the graph alive
|
|
159
|
-
const osc = ctx.createOscillator();
|
|
160
|
-
osc.frequency.value = 1; // sub-audible
|
|
161
|
-
osc.connect(proc);
|
|
162
|
-
proc.connect(ctx.destination);
|
|
163
|
-
osc.start();
|
|
164
|
-
|
|
165
|
-
setTimeout(() => {
|
|
166
|
-
osc.stop();
|
|
167
|
-
osc.disconnect();
|
|
168
|
-
proc.disconnect();
|
|
169
|
-
resolve(proc);
|
|
170
|
-
}, durationMs);
|
|
171
|
-
}
|
|
172
|
-
|
|
173
|
-
async function _measureNoiseFloor(ctx) {
|
|
174
|
-
try {
|
|
175
|
-
const analyser = ctx.createAnalyser();
|
|
176
|
-
analyser.fftSize = 256;
|
|
177
|
-
analyser.connect(ctx.destination);
|
|
178
|
-
|
|
179
|
-
// Silent source
|
|
180
|
-
const buf = ctx.createBuffer(1, ctx.sampleRate * 0.1, ctx.sampleRate);
|
|
181
|
-
const src = ctx.createBufferSource();
|
|
182
|
-
src.buffer = buf;
|
|
183
|
-
src.connect(analyser);
|
|
184
|
-
src.start();
|
|
185
|
-
|
|
186
|
-
await new Promise(r => setTimeout(r, 150));
|
|
187
|
-
|
|
188
|
-
const data = new Float32Array(analyser.frequencyBinCount);
|
|
189
|
-
analyser.getFloatFrequencyData(data);
|
|
190
|
-
analyser.disconnect();
|
|
191
|
-
|
|
192
|
-
// Limit to 32 bins to keep the payload small
|
|
193
|
-
const trimmed = Array.from(data.slice(0, 32)).map(v =>
|
|
194
|
-
isFinite(v) ? Math.pow(10, v / 20) : 0 // dB → linear
|
|
195
|
-
);
|
|
196
|
-
const mean = trimmed.reduce((s, v) => s + v, 0) / trimmed.length;
|
|
197
|
-
const std = Math.sqrt(
|
|
198
|
-
trimmed.reduce((s, v) => s + (v - mean) ** 2, 0) / trimmed.length
|
|
199
|
-
);
|
|
200
|
-
return { mean, std };
|
|
201
|
-
} catch (_) {
|
|
202
|
-
return { mean: 0, std: 0 };
|
|
203
|
-
}
|
|
204
|
-
}
|
|
205
|
-
|
|
206
|
-
function _percentile(arr, p) {
|
|
207
|
-
if (!arr.length) return 0;
|
|
208
|
-
const sorted = [...arr].sort((a, b) => a - b);
|
|
209
|
-
const idx = (p / 100) * (sorted.length - 1);
|
|
210
|
-
const lo = Math.floor(idx);
|
|
211
|
-
const hi = Math.ceil(idx);
|
|
212
|
-
return sorted[lo] + (sorted[hi] - sorted[lo]) * (idx - lo);
|
|
213
|
-
}
|
|
1
|
+
/**
|
|
2
|
+
* @svrnsec/pulse — AudioContext Oscillator Jitter
|
|
3
|
+
*
|
|
4
|
+
* Measures the scheduling jitter of the browser's audio pipeline.
|
|
5
|
+
* Real audio hardware callbacks are driven by a hardware interrupt (IRQ)
|
|
6
|
+
* from the sound card; the timing reflects the actual interrupt latency
|
|
7
|
+
* of the physical device. VM audio drivers (if present at all) are
|
|
8
|
+
* emulated and show either unrealistically low jitter or burst-mode
|
|
9
|
+
* scheduling artefacts that are statistically distinguishable.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* @param {object} [opts]
|
|
14
|
+
* @param {number} [opts.durationMs=2000] - how long to collect audio callbacks
|
|
15
|
+
* @param {number} [opts.bufferSize=256] - ScriptProcessorNode buffer size
|
|
16
|
+
* @returns {Promise<AudioJitter>}
|
|
17
|
+
*/
|
|
18
|
+
export async function collectAudioJitter(opts = {}) {
|
|
19
|
+
const { durationMs = 2000, bufferSize = 256 } = opts;
|
|
20
|
+
|
|
21
|
+
const base = {
|
|
22
|
+
available: false,
|
|
23
|
+
workletAvailable: false,
|
|
24
|
+
callbackJitterCV: 0,
|
|
25
|
+
noiseFloorMean: 0,
|
|
26
|
+
sampleRate: 0,
|
|
27
|
+
callbackCount: 0,
|
|
28
|
+
jitterTimings: [],
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
if (typeof AudioContext === 'undefined' && typeof webkitAudioContext === 'undefined') {
|
|
32
|
+
return base; // Node.js / server environment
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
let ctx;
|
|
36
|
+
try {
|
|
37
|
+
ctx = new (window.AudioContext || window.webkitAudioContext)();
|
|
38
|
+
} catch (_) {
|
|
39
|
+
return base;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// Some browsers require a user gesture before AudioContext can run.
|
|
43
|
+
if (ctx.state === 'suspended') {
|
|
44
|
+
try {
|
|
45
|
+
await ctx.resume();
|
|
46
|
+
} catch (_) {
|
|
47
|
+
await ctx.close().catch(() => {});
|
|
48
|
+
return base;
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const sampleRate = ctx.sampleRate;
|
|
53
|
+
const expectedInterval = (bufferSize / sampleRate) * 1000; // ms per callback
|
|
54
|
+
|
|
55
|
+
const jitterTimings = []; // absolute AudioContext.currentTime at each callback
|
|
56
|
+
const callbackDeltas = [];
|
|
57
|
+
|
|
58
|
+
const result = await new Promise((resolve) => {
|
|
59
|
+
// ── AudioWorklet (preferred — runs on dedicated real-time thread) ──────
|
|
60
|
+
const useWorklet = typeof AudioWorkletNode !== 'undefined';
|
|
61
|
+
base.workletAvailable = useWorklet;
|
|
62
|
+
|
|
63
|
+
if (useWorklet) {
|
|
64
|
+
// Inline worklet: send currentTime back via MessagePort every buffer
|
|
65
|
+
const workletCode = `
|
|
66
|
+
class PulseProbe extends AudioWorkletProcessor {
|
|
67
|
+
process(inputs, outputs) {
|
|
68
|
+
this.port.postMessage({ t: currentTime });
|
|
69
|
+
// Pass-through silence
|
|
70
|
+
for (const out of outputs)
|
|
71
|
+
for (const ch of out) ch.fill(0);
|
|
72
|
+
return true;
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
registerProcessor('pulse-probe', PulseProbe);
|
|
76
|
+
`;
|
|
77
|
+
const blob = new Blob([workletCode], { type: 'application/javascript' });
|
|
78
|
+
const blobUrl = URL.createObjectURL(blob);
|
|
79
|
+
|
|
80
|
+
ctx.audioWorklet.addModule(blobUrl).then(() => {
|
|
81
|
+
const node = new AudioWorkletNode(ctx, 'pulse-probe');
|
|
82
|
+
node.port.onmessage = (e) => {
|
|
83
|
+
jitterTimings.push(e.data.t * 1000); // convert to ms
|
|
84
|
+
};
|
|
85
|
+
node.connect(ctx.destination);
|
|
86
|
+
|
|
87
|
+
setTimeout(async () => {
|
|
88
|
+
node.disconnect();
|
|
89
|
+
URL.revokeObjectURL(blobUrl);
|
|
90
|
+
resolve(node);
|
|
91
|
+
}, durationMs);
|
|
92
|
+
}).catch(() => {
|
|
93
|
+
URL.revokeObjectURL(blobUrl);
|
|
94
|
+
_fallbackScriptProcessor(ctx, bufferSize, durationMs, jitterTimings, resolve);
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
} else {
|
|
98
|
+
_fallbackScriptProcessor(ctx, bufferSize, durationMs, jitterTimings, resolve);
|
|
99
|
+
}
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
// ── Compute deltas between successive callback times ────────────────────
|
|
103
|
+
for (let i = 1; i < jitterTimings.length; i++) {
|
|
104
|
+
callbackDeltas.push(jitterTimings[i] - jitterTimings[i - 1]);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
// ── Noise floor via AnalyserNode ─────────────────────────────────────────
|
|
108
|
+
// Feed a silent oscillator through an analyser; the FFT magnitude at silence
|
|
109
|
+
// reveals the hardware's thermal noise floor (varies per ADC/DAC chipset).
|
|
110
|
+
const noiseFloor = await _measureNoiseFloor(ctx);
|
|
111
|
+
|
|
112
|
+
await ctx.close().catch(() => {});
|
|
113
|
+
|
|
114
|
+
// ── Statistics ────────────────────────────────────────────────────────────
|
|
115
|
+
const mean = callbackDeltas.length
|
|
116
|
+
? callbackDeltas.reduce((s, v) => s + v, 0) / callbackDeltas.length
|
|
117
|
+
: 0;
|
|
118
|
+
const variance = callbackDeltas.length > 1
|
|
119
|
+
? callbackDeltas.reduce((s, v) => s + (v - mean) ** 2, 0) / (callbackDeltas.length - 1)
|
|
120
|
+
: 0;
|
|
121
|
+
const jitterCV = mean > 0 ? Math.sqrt(variance) / mean : 0;
|
|
122
|
+
|
|
123
|
+
return {
|
|
124
|
+
available: true,
|
|
125
|
+
workletAvailable: base.workletAvailable,
|
|
126
|
+
callbackJitterCV: jitterCV,
|
|
127
|
+
noiseFloorMean: noiseFloor.mean,
|
|
128
|
+
noiseFloorStd: noiseFloor.std,
|
|
129
|
+
sampleRate,
|
|
130
|
+
callbackCount: jitterTimings.length,
|
|
131
|
+
expectedIntervalMs: expectedInterval,
|
|
132
|
+
// Only include summary stats, not raw timings (privacy / size)
|
|
133
|
+
jitterMeanMs: mean,
|
|
134
|
+
jitterP95Ms: _percentile(callbackDeltas, 95),
|
|
135
|
+
};
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* @typedef {object} AudioJitter
|
|
140
|
+
* @property {boolean} available
|
|
141
|
+
* @property {boolean} workletAvailable
|
|
142
|
+
* @property {number} callbackJitterCV
|
|
143
|
+
* @property {number} noiseFloorMean
|
|
144
|
+
* @property {number} sampleRate
|
|
145
|
+
* @property {number} callbackCount
|
|
146
|
+
*/
|
|
147
|
+
|
|
148
|
+
// ---------------------------------------------------------------------------
|
|
149
|
+
// Internal helpers
|
|
150
|
+
// ---------------------------------------------------------------------------
|
|
151
|
+
|
|
152
|
+
function _fallbackScriptProcessor(ctx, bufferSize, durationMs, jitterTimings, resolve) {
|
|
153
|
+
// ScriptProcessorNode is deprecated but universally supported.
|
|
154
|
+
const proc = ctx.createScriptProcessor(bufferSize, 1, 1);
|
|
155
|
+
proc.onaudioprocess = () => {
|
|
156
|
+
jitterTimings.push(ctx.currentTime * 1000);
|
|
157
|
+
};
|
|
158
|
+
// Connect to keep the graph alive
|
|
159
|
+
const osc = ctx.createOscillator();
|
|
160
|
+
osc.frequency.value = 1; // sub-audible
|
|
161
|
+
osc.connect(proc);
|
|
162
|
+
proc.connect(ctx.destination);
|
|
163
|
+
osc.start();
|
|
164
|
+
|
|
165
|
+
setTimeout(() => {
|
|
166
|
+
osc.stop();
|
|
167
|
+
osc.disconnect();
|
|
168
|
+
proc.disconnect();
|
|
169
|
+
resolve(proc);
|
|
170
|
+
}, durationMs);
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
async function _measureNoiseFloor(ctx) {
|
|
174
|
+
try {
|
|
175
|
+
const analyser = ctx.createAnalyser();
|
|
176
|
+
analyser.fftSize = 256;
|
|
177
|
+
analyser.connect(ctx.destination);
|
|
178
|
+
|
|
179
|
+
// Silent source
|
|
180
|
+
const buf = ctx.createBuffer(1, ctx.sampleRate * 0.1, ctx.sampleRate);
|
|
181
|
+
const src = ctx.createBufferSource();
|
|
182
|
+
src.buffer = buf;
|
|
183
|
+
src.connect(analyser);
|
|
184
|
+
src.start();
|
|
185
|
+
|
|
186
|
+
await new Promise(r => setTimeout(r, 150));
|
|
187
|
+
|
|
188
|
+
const data = new Float32Array(analyser.frequencyBinCount);
|
|
189
|
+
analyser.getFloatFrequencyData(data);
|
|
190
|
+
analyser.disconnect();
|
|
191
|
+
|
|
192
|
+
// Limit to 32 bins to keep the payload small
|
|
193
|
+
const trimmed = Array.from(data.slice(0, 32)).map(v =>
|
|
194
|
+
isFinite(v) ? Math.pow(10, v / 20) : 0 // dB → linear
|
|
195
|
+
);
|
|
196
|
+
const mean = trimmed.reduce((s, v) => s + v, 0) / trimmed.length;
|
|
197
|
+
const std = Math.sqrt(
|
|
198
|
+
trimmed.reduce((s, v) => s + (v - mean) ** 2, 0) / trimmed.length
|
|
199
|
+
);
|
|
200
|
+
return { mean, std };
|
|
201
|
+
} catch (_) {
|
|
202
|
+
return { mean: 0, std: 0 };
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
function _percentile(arr, p) {
|
|
207
|
+
if (!arr.length) return 0;
|
|
208
|
+
const sorted = [...arr].sort((a, b) => a - b);
|
|
209
|
+
const idx = (p / 100) * (sorted.length - 1);
|
|
210
|
+
const lo = Math.floor(idx);
|
|
211
|
+
const hi = Math.ceil(idx);
|
|
212
|
+
return sorted[lo] + (sorted[hi] - sorted[lo]) * (idx - lo);
|
|
213
|
+
}
|