@shiihaa/breath-detection 1.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/dist/index.js ADDED
@@ -0,0 +1,545 @@
1
+ /**
2
+ * @shiihaa/breath-detection
3
+ * Real-time breath cycle detection using microphone energy analysis,
4
+ * spectral centroid classification, and optional BLE heart rate data.
5
+ *
6
+ * © 2026 shii · haa — Felix Zeller
7
+ * MIT License
8
+ */
9
+
10
+ // ─────────────────────────────────────────────────────────────────────────────
11
+ // Types
12
+ // ─────────────────────────────────────────────────────────────────────────────
13
+
14
+ // ─────────────────────────────────────────────────────────────────────────────
15
+ // BreathDetector
16
+ // ─────────────────────────────────────────────────────────────────────────────
17
+
18
+ class BreathDetector {
19
+ opts;
20
+ audioCtx = null;
21
+ analyser = null;
22
+ micStream = null;
23
+ source = null;
24
+ freqData = null;
25
+ timeData = null;
26
+
27
+ // State
28
+ running = false;
29
+ calibrated = false;
30
+ noiseFloor = 0;
31
+ breathMax = 0;
32
+ smoothedEnergy = 0;
33
+ smoothedCentroid = 500;
34
+ tickInterval = null;
35
+
36
+ // State machine
37
+ state = 'idle';
38
+ stateStart = 0;
39
+ aboveStart = 0;
40
+ belowStart = 0;
41
+ peakEnergy = 0;
42
+ cycleData = {};
43
+ recentCycleDurations = [];
44
+ lastCycleEndTime = 0;
45
+
46
+ // Centroid tracking per phase
47
+ centroidSumA1 = 0;
48
+ centroidCountA1 = 0;
49
+ centroidSumA2 = 0;
50
+ centroidCountA2 = 0;
51
+
52
+ // Energy trend
53
+ prevEnergy = 0;
54
+ energyRising = false;
55
+ energyFallingStart = 0;
56
+ energyPeakVal = 0;
57
+
58
+ // Peak-based detection
59
+ peakCount = 0;
60
+ lastPeakTime = 0;
61
+ peakCycleStart = 0;
62
+
63
+ // Auto-recalibration
64
+ recentEnergySamples = [];
65
+ lastRecalTime = 0;
66
+
67
+ // Callbacks
68
+ onCycleCallbacks[] = [];
69
+ onPhaseCallbacks[] = [];
70
+ onCalibrationCallbacks[] = [];
71
+ onEnergyCallbacks[] = [];
72
+
73
+ // External data (BLE)
74
+ externalHR = null;
75
+ prevHR = null;
76
+
77
+ constructor(options?) {
78
+ this.opts = {
79
+ fftSize: options?.fftSize ?? 4096,
80
+ smoothingAlpha: options?.smoothingAlpha ?? 0.25,
81
+ minCycleGapSeconds: options?.minCycleGapSeconds ?? 2.5,
82
+ minPhaseSeconds: options?.minPhaseSeconds ?? 1.5,
83
+ thresholdFactor: options?.thresholdFactor ?? 0.35,
84
+ freqLow: options?.freqLow ?? 150,
85
+ freqHigh: options?.freqHigh ?? 2500,
86
+ enableCentroid: options?.enableCentroid ?? true,
87
+ centroidThreshold: options?.centroidThreshold ?? 40,
88
+ };
89
+ }
90
+
91
+ // ── Public API ──────────────────────────────────────────────────────────
92
+
93
+ /** Start microphone capture and breath detection */
94
+ async start() {
95
+ try {
96
+ this.micStream = await navigator.mediaDevices.getUserMedia({
97
+ audio: { echoCancellation: false, noiseSuppression: false, autoGainControl: true }
98
+ });
99
+ this.audioCtx = new (window.AudioContext || (window).webkitAudioContext)();
100
+ this.source = this.audioCtx.createMediaStreamSource(this.micStream);
101
+ this.analyser = this.audioCtx.createAnalyser();
102
+ this.analyser.fftSize = this.opts.fftSize;
103
+ this.analyser.smoothingTimeConstant = 0.4;
104
+ this.source.connect(this.analyser);
105
+ this.freqData = new Uint8Array(this.analyser.frequencyBinCount);
106
+ this.timeData = new Uint8Array(this.analyser.fftSize);
107
+ this.running = true;
108
+ this.state = 'idle';
109
+ this.stateStart = Date.now();
110
+ return true;
111
+ } catch (e) {
112
+ console.error('[BreathDetector] Mic error:', e);
113
+ return false;
114
+ }
115
+ }
116
+
117
+ /** Stop capture and release resources */
118
+ stop() {
119
+ this.running = false;
120
+ if (this.tickInterval) { clearInterval(this.tickInterval); this.tickInterval = null; }
121
+ if (this.source) { try { this.source.disconnect(); } catch (_) {} }
122
+ if (this.micStream) { this.micStream.getTracks().forEach(t => t.stop()); }
123
+ if (this.audioCtx) { this.audioCtx.close().catch(() => {}); }
124
+ this.audioCtx = null;
125
+ this.analyser = null;
126
+ this.source = null;
127
+ this.micStream = null;
128
+ this.calibrated = false;
129
+ this.smoothedEnergy = 0;
130
+ }
131
+
132
+ /** Calibrate: 2s noise floor + 4s breath samples. Returns when done. */
133
+ async calibrate() {
134
+ if (!this.analyser) return { noiseFloor: 0, breathMax: 0, success: false };
135
+
136
+ // Phase 1: Noise floor (2s)
137
+ const noiseSamples = [];
138
+ await this.sampleFor(2000, () => {
139
+ this.computeEnergy();
140
+ noiseSamples.push(this.smoothedEnergy);
141
+ });
142
+ this.noiseFloor = noiseSamples.reduce((a, b) => a + b, 0) / noiseSamples.length;
143
+
144
+ // Phase 2: Breath samples (4s)
145
+ const breathSamples = [];
146
+ await this.sampleFor(4000, () => {
147
+ this.computeEnergy();
148
+ breathSamples.push(this.smoothedEnergy);
149
+ });
150
+ breathSamples.sort((a, b) => b - a);
151
+ const top15 = breathSamples.slice(0, Math.max(5, Math.floor(breathSamples.length * 0.15)));
152
+ this.breathMax = top15.reduce((a, b) => a + b, 0) / top15.length;
153
+
154
+ if (this.breathMax - this.noiseFloor < 0.005) {
155
+ this.breathMax = Math.max(this.noiseFloor * 2.0, this.noiseFloor + 0.02);
156
+ }
157
+
158
+ this.calibrated = true;
159
+ const result = {
160
+ noiseFloor: this.noiseFloor,
161
+ breathMax: this.breathMax,
162
+ success: true,
163
+ };
164
+ this.onCalibrationCallbacks.forEach(cb => cb(result));
165
+ return result;
166
+ }
167
+
168
+ /** Start the detection loop (call after calibrate) */
169
+ startDetection() {
170
+ if (!this.calibrated || !this.running) return;
171
+ this.resetState();
172
+ if (this.tickInterval) clearInterval(this.tickInterval);
173
+ this.tickInterval = setInterval(() => this.tick(), 33); // ~30fps
174
+ }
175
+
176
+ /** Stop detection loop (keeps mic open) */
177
+ stopDetection() {
178
+ if (this.tickInterval) { clearInterval(this.tickInterval); this.tickInterval = null; }
179
+ }
180
+
181
+ /** Feed external heart rate for enhanced detection (optional) */
182
+ setHeartRate(bpm) {
183
+ this.externalHR = bpm;
184
+ }
185
+
186
+ /** Get current state */
187
+ getState() {
188
+ return {
189
+ running: this.running,
190
+ calibrated: this.calibrated,
191
+ phase: this.state,
192
+ energy: this.getNormalizedEnergy(),
193
+ centroid: this.smoothedCentroid,
194
+ noiseFloor: this.noiseFloor,
195
+ breathMax: this.breathMax,
196
+ };
197
+ }
198
+
199
+ // ── Event Registration ──────────────────────────────────────────────────
200
+
201
+ /** Called when a full breath cycle is detected */
202
+ onCycle(callback) { this.onCycleCallbacks.push(callback); }
203
+
204
+ /** Called on every tick with current phase info */
205
+ onPhase(callback) { this.onPhaseCallbacks.push(callback); }
206
+
207
+ /** Called when calibration completes */
208
+ onCalibration(callback) { this.onCalibrationCallbacks.push(callback); }
209
+
210
+ /** Called on every tick with raw energy + centroid */
211
+ onEnergy(callback) { this.onEnergyCallbacks.push(callback); }
212
+
213
+ // ── Private ─────────────────────────────────────────────────────────────
214
+
215
+ resetState() {
216
+ this.state = 'idle';
217
+ this.stateStart = Date.now();
218
+ this.aboveStart = 0;
219
+ this.belowStart = 0;
220
+ this.peakEnergy = 0;
221
+ this.cycleData = {};
222
+ this.recentCycleDurations = [];
223
+ this.lastCycleEndTime = 0;
224
+ this.centroidSumA1 = 0; this.centroidCountA1 = 0;
225
+ this.centroidSumA2 = 0; this.centroidCountA2 = 0;
226
+ this.prevEnergy = 0;
227
+ this.energyRising = false;
228
+ this.energyFallingStart = 0;
229
+ this.energyPeakVal = 0;
230
+ this.peakCount = 0;
231
+ this.lastPeakTime = 0;
232
+ this.peakCycleStart = 0;
233
+ this.recentEnergySamples = [];
234
+ this.lastRecalTime = 0;
235
+ }
236
+
237
+ computeEnergy() {
238
+ if (!this.analyser || !this.freqData || !this.timeData) return 0;
239
+ this.analyser.getByteFrequencyData(this.freqData);
240
+ this.analyser.getByteTimeDomainData(this.timeData);
241
+
242
+ const nyquist = this.audioCtx.sampleRate / 2;
243
+ const binSize = nyquist / this.analyser.frequencyBinCount;
244
+ const lowBin = Math.floor(this.opts.freqLow / binSize);
245
+ const highBin = Math.min(Math.ceil(this.opts.freqHigh / binSize), this.analyser.frequencyBinCount - 1);
246
+
247
+ let freqSum = 0;
248
+ let centroidNum = 0, centroidDen = 0;
249
+ for (let i = lowBin; i <= highBin; i++) {
250
+ const mag = this.freqData[i];
251
+ freqSum += mag;
252
+ const freq = i * binSize;
253
+ centroidNum += freq * mag;
254
+ centroidDen += mag;
255
+ }
256
+ const freqEnergy = freqSum / ((highBin - lowBin + 1) * 255);
257
+
258
+ if (this.opts.enableCentroid) {
259
+ const rawCentroid = centroidDen > 0 ? centroidNum / centroidDen : 500;
260
+ this.smoothedCentroid = 0.2 * rawCentroid + 0.8 * this.smoothedCentroid;
261
+ }
262
+
263
+ let rmsSum = 0;
264
+ for (let j = 0; j < this.timeData.length; j++) {
265
+ const v = (this.timeData[j] - 128) / 128;
266
+ rmsSum += v * v;
267
+ }
268
+ const rmsVal = Math.sqrt(rmsSum / this.timeData.length);
269
+
270
+ const combined = freqEnergy * 0.7 + rmsVal * 0.3;
271
+ this.smoothedEnergy = this.smoothedEnergy * (1 - this.opts.smoothingAlpha) + combined * this.opts.smoothingAlpha;
272
+
273
+ // Auto-recalibration
274
+ if (this.calibrated) {
275
+ this.recentEnergySamples.push(this.smoothedEnergy);
276
+ if (this.recentEnergySamples.length > 300) this.recentEnergySamples.shift();
277
+ const now = Date.now();
278
+ if (now - this.lastRecalTime > 10000 && this.recentEnergySamples.length >= 300) {
279
+ this.lastRecalTime = now;
280
+ const sorted = [...this.recentEnergySamples].sort((a, b) => a - b);
281
+ const newFloor = sorted[Math.floor(sorted.length * 0.15)];
282
+ const newMax = sorted[Math.floor(sorted.length * 0.90)];
283
+ if (newFloor > 0 && newMax > newFloor * 1.3) {
284
+ this.noiseFloor = this.noiseFloor * 0.7 + newFloor * 0.3;
285
+ this.breathMax = this.breathMax * 0.7 + newMax * 0.3;
286
+ }
287
+ }
288
+ }
289
+
290
+ return this.smoothedEnergy;
291
+ }
292
+
293
+ getThreshold() {
294
+ if (!this.calibrated) return 0.05;
295
+ const range = this.breathMax - this.noiseFloor;
296
+ if (range < 0.003) return 0.008;
297
+ return Math.max(this.noiseFloor + range * Math.max(this.opts.thresholdFactor, 0.06), 0.008);
298
+ }
299
+
300
+ getThresholdLow() {
301
+ if (!this.calibrated) return 0.02;
302
+ const range = this.breathMax - this.noiseFloor;
303
+ if (range < 0.003) return 0.004;
304
+ return this.noiseFloor + range * 0.20;
305
+ }
306
+
307
+ getNormalizedEnergy() {
308
+ if (!this.calibrated) return 0;
309
+ const range = this.breathMax - this.noiseFloor;
310
+ if (range < 0.001) return 0;
311
+ return Math.max(0, Math.min(1, (this.smoothedEnergy - this.noiseFloor) / range));
312
+ }
313
+
314
+ tick() {
315
+ if (!this.running || !this.calibrated) return;
316
+ const now = Date.now();
317
+ const energy = this.computeEnergy();
318
+ const th = this.getThreshold();
319
+ const thLow = this.getThresholdLow();
320
+ let aboveTh = this.smoothedEnergy > th;
321
+ let belowThLow = this.smoothedEnergy < thLow;
322
+
323
+ // Energy trend detection
324
+ const energyDelta = energy - this.prevEnergy;
325
+ const wasRising = this.energyRising;
326
+ if (energyDelta > 0) {
327
+ this.energyRising = true;
328
+ if (energy > this.energyPeakVal) this.energyPeakVal = energy;
329
+ this.energyFallingStart = 0;
330
+ } else if (energyDelta < 0) {
331
+ if (this.energyRising && !this.energyFallingStart) this.energyFallingStart = now;
332
+ this.energyRising = false;
333
+ }
334
+
335
+ const significantDrop = this.energyPeakVal > 0 && energy < this.energyPeakVal * 0.6;
336
+ const fallingLongEnough = this.energyFallingStart > 0 && (now - this.energyFallingStart) > 500;
337
+ const minStateMs = this.opts.minPhaseSeconds * 1000;
338
+
339
+ if (significantDrop && fallingLongEnough) {
340
+ if ((this.state === 'active1' || this.state === 'active2') && (now - this.stateStart) > minStateMs) {
341
+ belowThLow = true;
342
+ this.energyPeakVal = 0;
343
+ this.energyFallingStart = 0;
344
+ }
345
+ }
346
+ if (!wasRising && this.energyRising && energy > this.noiseFloor * 1.2) {
347
+ if (this.state === 'silent1' || this.state === 'silent2') aboveTh = true;
348
+ }
349
+
350
+ // Peak-based cycle detection (pauseless breathing fallback)
351
+ if (wasRising && !this.energyRising && this.energyPeakVal > this.noiseFloor * 1.3) {
352
+ const timeSinceLastPeak = this.lastPeakTime > 0 ? (now - this.lastPeakTime) : 99999;
353
+ if (timeSinceLastPeak > 1500) {
354
+ this.peakCount++;
355
+ if (this.peakCount === 1) this.peakCycleStart = now;
356
+ this.lastPeakTime = now;
357
+ if (this.peakCount >= 2 && this.peakCycleStart > 0) {
358
+ const peakCycleMs = now - this.peakCycleStart;
359
+ if (this.state === 'active1' && peakCycleMs > this.opts.minCycleGapSeconds * 1000) {
360
+ const halfCycle = Math.round(peakCycleMs / 2);
361
+ this.cycleData = {
362
+ active1Start: this.peakCycleStart, active1End: this.peakCycleStart + halfCycle,
363
+ silent1Start: this.peakCycleStart + halfCycle, silent1End: this.peakCycleStart + halfCycle,
364
+ active2Start: this.peakCycleStart + halfCycle, active2End: now, silent2Start: now,
365
+ };
366
+ this.completeCycle(now, 'peak');
367
+ this.peakCount = 1;
368
+ this.peakCycleStart = now;
369
+ } else if (this.state !== 'active1') {
370
+ this.peakCount = 0;
371
+ this.peakCycleStart = 0;
372
+ }
373
+ }
374
+ }
375
+ }
376
+
377
+ this.prevEnergy = energy;
378
+
379
+ // Centroid tracking
380
+ if (this.state === 'active1') { this.centroidSumA1 += this.smoothedCentroid; this.centroidCountA1++; }
381
+ else if (this.state === 'active2') { this.centroidSumA2 += this.smoothedCentroid; this.centroidCountA2++; }
382
+ if (this.state === 'active1' || this.state === 'active2') {
383
+ if (energy > this.peakEnergy) this.peakEnergy = energy;
384
+ }
385
+
386
+ // State machine
387
+ const ACTIVE_MS = 250;
388
+ const SILENT_MS = 500;
389
+
390
+ if (this.state === 'idle') {
391
+ if (aboveTh) {
392
+ if (!this.aboveStart) this.aboveStart = now;
393
+ if (now - this.aboveStart >= ACTIVE_MS) {
394
+ this.resetCycleData();
395
+ this.cycleData.active1Start = this.aboveStart;
396
+ this.aboveStart = 0; this.belowStart = 0;
397
+ this.changeState('active1', now);
398
+ }
399
+ } else { this.aboveStart = 0; }
400
+ } else if (this.state === 'active1') {
401
+ if (!belowThLow) { this.belowStart = 0; }
402
+ else {
403
+ if (!this.belowStart) this.belowStart = now;
404
+ if (now - this.belowStart >= SILENT_MS) {
405
+ this.cycleData.active1End = this.belowStart;
406
+ this.cycleData.silent1Start = this.belowStart;
407
+ this.belowStart = 0; this.aboveStart = 0;
408
+ this.changeState('silent1', now);
409
+ }
410
+ }
411
+ } else if (this.state === 'silent1') {
412
+ if (!aboveTh) { this.aboveStart = 0; }
413
+ else {
414
+ if (!this.aboveStart) this.aboveStart = now;
415
+ if (now - this.aboveStart >= ACTIVE_MS) {
416
+ this.cycleData.silent1End = this.aboveStart;
417
+ this.cycleData.active2Start = this.aboveStart;
418
+ this.aboveStart = 0; this.belowStart = 0;
419
+ this.changeState('active2', now);
420
+ }
421
+ }
422
+ } else if (this.state === 'active2') {
423
+ if (!belowThLow) { this.belowStart = 0; }
424
+ else {
425
+ if (!this.belowStart) this.belowStart = now;
426
+ if (now - this.belowStart >= SILENT_MS) {
427
+ this.cycleData.active2End = this.belowStart;
428
+ this.cycleData.silent2Start = this.belowStart;
429
+ this.belowStart = 0; this.aboveStart = 0;
430
+ this.changeState('silent2', now);
431
+ this.completeCycle(now, 'threshold');
432
+ }
433
+ }
434
+ } else if (this.state === 'silent2') {
435
+ if (!aboveTh) { this.aboveStart = 0; }
436
+ else {
437
+ if (!this.aboveStart) this.aboveStart = now;
438
+ if (now - this.aboveStart >= ACTIVE_MS) {
439
+ this.resetCycleData();
440
+ this.cycleData.active1Start = this.aboveStart;
441
+ this.aboveStart = 0; this.belowStart = 0;
442
+ this.changeState('active1', now);
443
+ }
444
+ }
445
+ }
446
+
447
+ // Emit phase event
448
+ const norm = this.getNormalizedEnergy();
449
+ const phaseMap: Record<string, BreathPhaseEvent['phase']> = {
450
+ idle: 'idle', active1: 'inhale', silent1: 'transition', active2: 'exhale', silent2: 'pause'
451
+ };
452
+ this.onPhaseCallbacks.forEach(cb => cb({
453
+ phase: phaseMap[this.state] || 'idle',
454
+ energy: norm,
455
+ centroid: this.smoothedCentroid,
456
+ phaseTime: now - this.stateStart,
457
+ }));
458
+ this.onEnergyCallbacks.forEach(cb => cb(norm, this.smoothedCentroid));
459
+ }
460
+
461
+ changeState(newState, now) {
462
+ this.state = newState;
463
+ this.stateStart = now;
464
+ }
465
+
466
+ resetCycleData() {
467
+ this.cycleData = {};
468
+ this.peakEnergy = 0;
469
+ this.centroidSumA1 = 0; this.centroidCountA1 = 0;
470
+ this.centroidSumA2 = 0; this.centroidCountA2 = 0;
471
+ this.peakCount = 0;
472
+ this.peakCycleStart = 0;
473
+ }
474
+
475
+ completeCycle(now, method: 'threshold' | 'peak') {
476
+ const minGapMs = this.opts.minCycleGapSeconds * 1000;
477
+ if (this.lastCycleEndTime > 0 && (now - this.lastCycleEndTime) < minGapMs) return;
478
+
479
+ const d = this.cycleData;
480
+ const active1Ms = (d.active1End || 0) - (d.active1Start || 0);
481
+ const silent1Ms = (d.silent1End || 0) - (d.silent1Start || 0);
482
+ const active2Ms = (d.active2End || 0) - (d.active2Start || 0);
483
+ const cycleMs = (d.active2End || now) - (d.active1Start || now);
484
+
485
+ if (cycleMs < minGapMs) return;
486
+
487
+ let inhaleMs = active1Ms;
488
+ let exhaleMs = active2Ms;
489
+ let labelSwapped = false;
490
+
491
+ // Spectral centroid classification
492
+ if (this.opts.enableCentroid) {
493
+ const c1 = this.centroidCountA1 > 0 ? this.centroidSumA1 / this.centroidCountA1 : 0;
494
+ const c2 = this.centroidCountA2 > 0 ? this.centroidSumA2 / this.centroidCountA2 : 0;
495
+ if (c1 > 0 && c2 > 0 && (c1 - c2) < -this.opts.centroidThreshold) {
496
+ inhaleMs = active2Ms;
497
+ exhaleMs = active1Ms;
498
+ labelSwapped = true;
499
+ }
500
+ }
501
+
502
+ // Duration fallback
503
+ if (!labelSwapped && active1Ms > active2Ms * 3 && active2Ms < 1000) {
504
+ const total = active1Ms + silent1Ms + active2Ms;
505
+ inhaleMs = Math.round(total * 0.5);
506
+ exhaleMs = Math.round(total * 0.5);
507
+ }
508
+
509
+ const holdInMs = silent1Ms > 1500 ? silent1Ms : 0;
510
+
511
+ let confidence = 100;
512
+ if (active1Ms < 500) confidence -= 20;
513
+ if (active2Ms < 500) confidence -= 20;
514
+ if (method === 'peak') confidence -= 10;
515
+ confidence = Math.max(0, Math.min(100, confidence));
516
+
517
+ this.lastCycleEndTime = now;
518
+ this.recentCycleDurations.push(cycleMs);
519
+ if (this.recentCycleDurations.length > 8) this.recentCycleDurations.shift();
520
+
521
+ const cycle = {
522
+ inhaleMs, exhaleMs, holdInMs, holdOutMs: 0,
523
+ cycleMs, peakEnergy: this.peakEnergy,
524
+ confidence, labelSwapped,
525
+ centroidA1: Math.round(this.centroidCountA1 > 0 ? this.centroidSumA1 / this.centroidCountA1 : 0),
526
+ centroidA2: Math.round(this.centroidCountA2 > 0 ? this.centroidSumA2 / this.centroidCountA2 : 0),
527
+ method, timestamp: now,
528
+ };
529
+
530
+ this.onCycleCallbacks.forEach(cb => cb(cycle));
531
+ }
532
+
533
+ sampleFor(ms, fn: () => void) {
534
+ return new Promise(resolve => {
535
+ const start = Date.now();
536
+ const iv = setInterval(() => {
537
+ fn();
538
+ if (Date.now() - start >= ms) { clearInterval(iv); resolve(); }
539
+ }, 33);
540
+ });
541
+ }
542
+ }
543
+
544
+ // Default export
545
+ default BreathDetector;