@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/LICENSE +21 -0
- package/README.md +161 -0
- package/dist/index.d.ts +109 -0
- package/dist/index.js +545 -0
- package/dist/index.mjs +545 -0
- package/package.json +37 -0
package/dist/index.mjs
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
|
+
export 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
|
+
export default BreathDetector;
|
package/package.json
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@shiihaa/breath-detection",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"description": "Real-time breath detection using microphone energy analysis, spectral centroid classification, and BLE heart rate data. Works in browsers and Capacitor apps.",
|
|
5
|
+
"main": "dist/index.js",
|
|
6
|
+
"module": "dist/index.mjs",
|
|
7
|
+
"types": "dist/index.d.ts",
|
|
8
|
+
"files": ["dist"],
|
|
9
|
+
"scripts": {
|
|
10
|
+
"build": "node build.js",
|
|
11
|
+
"prepublishOnly": "npm run build"
|
|
12
|
+
},
|
|
13
|
+
"keywords": [
|
|
14
|
+
"breath-detection",
|
|
15
|
+
"breathing",
|
|
16
|
+
"breathwork",
|
|
17
|
+
"biofeedback",
|
|
18
|
+
"audio-analysis",
|
|
19
|
+
"web-audio",
|
|
20
|
+
"capacitor",
|
|
21
|
+
"microphone",
|
|
22
|
+
"spectral-centroid",
|
|
23
|
+
"respiratory",
|
|
24
|
+
"HRV",
|
|
25
|
+
"BLE"
|
|
26
|
+
],
|
|
27
|
+
"author": "Felix Zeller <felix@shiihaa.app>",
|
|
28
|
+
"license": "MIT",
|
|
29
|
+
"repository": {
|
|
30
|
+
"type": "git",
|
|
31
|
+
"url": "https://github.com/shiihaa-app/breath-detection"
|
|
32
|
+
},
|
|
33
|
+
"homepage": "https://github.com/shiihaa-app/breath-detection#readme",
|
|
34
|
+
"bugs": {
|
|
35
|
+
"url": "https://github.com/shiihaa-app/breath-detection/issues"
|
|
36
|
+
}
|
|
37
|
+
}
|